parawatch 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/dist/adapters/claudeCode.js +226 -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 +360 -0
- package/dist/core/desktop.js +78 -0
- package/dist/core/follow.js +70 -0
- package/dist/core/format.js +261 -0
- package/dist/core/integrate.js +114 -0
- package/dist/core/ops.js +271 -0
- package/dist/core/runs.js +160 -0
- package/dist/core/skill.js +67 -0
- package/dist/core/snapshot.js +24 -0
- package/dist/core/state.js +95 -0
- package/dist/core/store.js +128 -0
- package/dist/core/types.js +5 -0
- package/package.json +57 -4
- package/skills/parawatch/SKILL.md +105 -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,207 @@
|
|
|
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
|
+
- **Did my answer reach the agent?** When you answer a card, the agent waiting on it wakes up. You do not go back to the chat to say "I decided".
|
|
15
|
+
|
|
16
|
+
parawatch is a CLI first. Agents use the CLI, and so can you. There is no server and nothing leaves your machine. On top of the CLI, this repository has a [desktop app](apps/desktop/README.md) (the panel above, a menu bar count, a Dock badge and notifications).
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
$ parawatch status
|
|
20
|
+
NEEDS YOU 3
|
|
21
|
+
c1 decide game/139 Which ending for the shake box? 12m
|
|
22
|
+
c2 try game/139 Try build 139: does the glass read as glass? 4m
|
|
23
|
+
c3 blocked app Install the iPad simulator runtime 1h
|
|
24
|
+
|
|
25
|
+
AGENTS 5 working
|
|
26
|
+
app
|
|
27
|
+
● Interval lesson screen 1m Bash: Build the app
|
|
28
|
+
○ App Director 24m PR 18 passed. Waiting for the simulator runtime.
|
|
29
|
+
game
|
|
30
|
+
● Game Director 1s Bash: Wait for answers
|
|
31
|
+
├ ● 139 shake round 2 1s Bash: Run tests
|
|
32
|
+
├ ● Generate the level icons 2m exec: python3 make_icons.py
|
|
33
|
+
└ ● Glass shader pass 9m Bash: Capture the tank
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
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`.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
There are three parts. Most people want all three.
|
|
41
|
+
|
|
42
|
+
**The CLI**, which agents and you use:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
npm install -g parawatch
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**The agent skill**, so Claude Code and Codex agents learn the protocol on their own:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
parawatch skill install
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
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.
|
|
55
|
+
|
|
56
|
+
**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).
|
|
57
|
+
|
|
58
|
+
### Platforms
|
|
59
|
+
|
|
60
|
+
| | macOS | Linux | Windows |
|
|
61
|
+
|--|--|--|--|
|
|
62
|
+
| CLI | used daily | tests pass in CI | not supported yet |
|
|
63
|
+
| Desktop app | used daily | untested | untested |
|
|
64
|
+
| Agent logs read | Claude Code, Codex | same | |
|
|
65
|
+
|
|
66
|
+
Node 20 or newer.
|
|
67
|
+
|
|
68
|
+
## Concepts
|
|
69
|
+
|
|
70
|
+
| | |
|
|
71
|
+
|--|--|
|
|
72
|
+
| **Project** | A name and one or more paths. Git worktrees of a path belong to the project too. |
|
|
73
|
+
| **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`. |
|
|
75
|
+
| **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
|
+
|
|
77
|
+
## For the human
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
parawatch status # what needs you, then what is running
|
|
81
|
+
parawatch cards # open and answered cards
|
|
82
|
+
parawatch show c4 # one card with its body, options and attachments
|
|
83
|
+
parawatch answer c4 -c 2 # pick option 2
|
|
84
|
+
parawatch answer c4 -m "fill it to the rim" # or just say something
|
|
85
|
+
parawatch answer c5 -m "glass is too dark" --attach ~/Desktop/shot.png
|
|
86
|
+
parawatch runs --since 12 # agents seen in the last 12 hours
|
|
87
|
+
parawatch runs -p game --all # include finished ones
|
|
88
|
+
parawatch runs --wide # with runtime and ids
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Register projects once:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
parawatch project add game ~/dev/game
|
|
95
|
+
parawatch project add app ~/dev/app ~/notes/app
|
|
96
|
+
parawatch claim 811a9612 -p game --role lead --title "Game Director"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`claim` is for sessions that do not start inside a project path (an agent run from a notes folder, a scratch directory). Children of a claimed session follow it.
|
|
100
|
+
|
|
101
|
+
## For agents
|
|
102
|
+
|
|
103
|
+
Add the agent protocol to a project's instruction file:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
parawatch init -f AGENTS.md -p game # or CLAUDE.md
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
It writes a short block between `<!-- parawatch:begin -->` and `<!-- parawatch:end -->` and replaces it in place on later runs. The protocol in short:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
parawatch ask -p game "Which ending?" -o "Side tank" -o "Top box" -r 2 -b "two or three lines"
|
|
113
|
+
parawatch try -p game "Try build 139" --open /path/Game.app --look "does the glass read?"
|
|
114
|
+
parawatch block -p app "Install the iPad simulator runtime"
|
|
115
|
+
parawatch todo -p game "Check the GitHub bill" --since 2026-10-01
|
|
116
|
+
parawatch wait -p game # run in the background; exits when the human answers
|
|
117
|
+
parawatch comment c4 "baking the top box now" # a note on the card; does not wake anyone
|
|
118
|
+
parawatch close c4 -n "used the top box"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`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
|
+
|
|
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.
|
|
124
|
+
|
|
125
|
+
### Agent skill
|
|
126
|
+
|
|
127
|
+
[`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.
|
|
128
|
+
|
|
129
|
+
`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.
|
|
130
|
+
|
|
131
|
+
### Waking a Claude Code session
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
135
|
+
As a backstop, the hook adds any unhandled answers to the next turn of a session in that project:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"hooks": {
|
|
140
|
+
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "parawatch hook claude-code" }] }],
|
|
141
|
+
"SessionStart": [{ "hooks": [{ "type": "command", "command": "parawatch hook claude-code" }] }]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The hook only peeks. The agent's own `wait` or `inbox` moves its cursor.
|
|
147
|
+
|
|
148
|
+
### Other runtimes
|
|
149
|
+
|
|
150
|
+
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
|
+
|
|
152
|
+
## Observed agents
|
|
153
|
+
|
|
154
|
+
parawatch reads agent logs. It never writes to them and never sends them anywhere.
|
|
155
|
+
|
|
156
|
+
| Runtime | Where it reads | What it shows |
|
|
157
|
+
|--|--|--|
|
|
158
|
+
| 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
|
+
| 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
|
+
|
|
161
|
+
States: `●` working, `◐` waiting, `○` idle, `✓` done, `!` stalled (quiet longer than its tool's timeout allows), `✗` failed.
|
|
162
|
+
|
|
163
|
+
Runs that match no registered project show under `other`. A run belongs to a project by, in order: a claim, its working directory, its parent's project, a project path named in its first prompt.
|
|
164
|
+
|
|
165
|
+
### Adding a runtime
|
|
166
|
+
|
|
167
|
+
An adapter is one file in `src/adapters/` that implements:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
interface Adapter {
|
|
171
|
+
name: string;
|
|
172
|
+
scan(opts: { sinceMs: number; now: number }): Run[];
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
It reads only, reads only files touched since `sinceMs`, and reads only the head and tail of large logs.
|
|
177
|
+
|
|
178
|
+
## Apps, plugins and dashboards
|
|
179
|
+
|
|
180
|
+
| | |
|
|
181
|
+
|--|--|
|
|
182
|
+
| [`apps/desktop`](apps/desktop/README.md) | The desktop app. Reads and writes the store directly, so it works without the CLI on `PATH`. |
|
|
183
|
+
| [`packages/panel`](packages/panel) | The panel both of them show, as plain DOM, with a harness page for working on it. |
|
|
184
|
+
|
|
185
|
+
Anything else can build on the same two commands:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
parawatch status --json # one snapshot: projects, cards, runs
|
|
189
|
+
parawatch watch # every new event as one JSON line
|
|
190
|
+
parawatch watch --from 120 # starting after event 120
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Storage
|
|
194
|
+
|
|
195
|
+
Everything lives in one append-only file, `~/.local/state/parawatch/events.jsonl` (`$PARAWATCH_HOME`, or `$XDG_STATE_HOME/parawatch`). Writers take a short lock, so several agents can write at once. Cursors live next to it in `cursors/`. Delete the folder to start over.
|
|
196
|
+
|
|
197
|
+
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
|
+
|
|
199
|
+
## Not goals
|
|
200
|
+
|
|
201
|
+
- 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.
|
|
203
|
+
- Replacing your issue tracker. Cards are short-lived questions, not the backlog.
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
/** Work out what a Claude Code transcript is doing from its last records. */
|
|
44
|
+
export function readClaudeTail(recs, kind) {
|
|
45
|
+
const out = { state: "unknown" };
|
|
46
|
+
let lastConv;
|
|
47
|
+
for (const r of recs) {
|
|
48
|
+
if (!isObj(r))
|
|
49
|
+
continue;
|
|
50
|
+
const ts = str(r.timestamp);
|
|
51
|
+
if (ts && (!out.lastActivity || ts > out.lastActivity))
|
|
52
|
+
out.lastActivity = ts;
|
|
53
|
+
if (str(r.cwd))
|
|
54
|
+
out.cwd = str(r.cwd);
|
|
55
|
+
if (r.type === "custom-title" && str(r.customTitle))
|
|
56
|
+
out.title = str(r.customTitle);
|
|
57
|
+
if (r.type === "agent-name" && str(r.agentName) && !out.title)
|
|
58
|
+
out.title = str(r.agentName);
|
|
59
|
+
if (isConversation(r))
|
|
60
|
+
lastConv = r;
|
|
61
|
+
}
|
|
62
|
+
if (!lastConv)
|
|
63
|
+
return out;
|
|
64
|
+
const bs = blocks(lastConv);
|
|
65
|
+
if (lastConv.type === "assistant") {
|
|
66
|
+
const tool = [...bs].reverse().find((b) => b.type === "tool_use");
|
|
67
|
+
if (tool) {
|
|
68
|
+
out.state = "working";
|
|
69
|
+
out.action = toolSummary(tool);
|
|
70
|
+
const t = tool.input?.timeout;
|
|
71
|
+
if (typeof t === "number")
|
|
72
|
+
out.pendingTimeoutMs = t;
|
|
73
|
+
}
|
|
74
|
+
else if (bs.some((b) => b.type === "text" && b.text?.trim())) {
|
|
75
|
+
out.state = kind === "subagent" ? "done" : "idle";
|
|
76
|
+
const text = bs.filter((b) => b.type === "text").map((b) => b.text ?? "").join(" ");
|
|
77
|
+
out.action = oneLine(text, 100);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
out.state = "working";
|
|
81
|
+
out.action = "thinking";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
out.state = "working";
|
|
86
|
+
const result = bs.find((b) => b.type === "tool_result");
|
|
87
|
+
out.action = result ? "thinking" : "reading a new message";
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
function firstPrompt(file) {
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const r of readHeadLines(file, HEAD_BYTES)) {
|
|
94
|
+
if (!isObj(r))
|
|
95
|
+
continue;
|
|
96
|
+
if (!out.startedAt && str(r.timestamp))
|
|
97
|
+
out.startedAt = str(r.timestamp);
|
|
98
|
+
if (!out.cwd && str(r.cwd))
|
|
99
|
+
out.cwd = str(r.cwd);
|
|
100
|
+
if (!out.prompt)
|
|
101
|
+
out.prompt = promptText(r);
|
|
102
|
+
if (out.prompt && out.cwd && out.startedAt)
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
function readMeta(file) {
|
|
108
|
+
try {
|
|
109
|
+
const data = JSON.parse(fs.readFileSync(file.replace(/\.jsonl$/, ".meta.json"), "utf8"));
|
|
110
|
+
return isObj(data) ? data : {};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const STALL_MS = 20 * 60_000;
|
|
117
|
+
export function applyStall(run, pendingTimeoutMs, now) {
|
|
118
|
+
if (run.state !== "working" || !run.lastActivity)
|
|
119
|
+
return;
|
|
120
|
+
const quiet = now - Date.parse(run.lastActivity);
|
|
121
|
+
const allowed = Math.max(STALL_MS, (pendingTimeoutMs ?? 0) + 2 * 60_000);
|
|
122
|
+
if (quiet > allowed)
|
|
123
|
+
run.state = "stalled";
|
|
124
|
+
}
|
|
125
|
+
function listJsonl(dir) {
|
|
126
|
+
try {
|
|
127
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => path.join(dir, f));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function fresh(file, sinceMs) {
|
|
134
|
+
try {
|
|
135
|
+
return fs.statSync(file).mtimeMs >= sinceMs;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export const claudeCode = {
|
|
142
|
+
name: "claude-code",
|
|
143
|
+
scan(opts) {
|
|
144
|
+
const root = claudeProjectsDir();
|
|
145
|
+
let dirs;
|
|
146
|
+
try {
|
|
147
|
+
dirs = fs.readdirSync(root).map((d) => path.join(root, d));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
const runs = [];
|
|
153
|
+
for (const dir of dirs) {
|
|
154
|
+
for (const file of listJsonl(dir)) {
|
|
155
|
+
if (fresh(file, opts.sinceMs))
|
|
156
|
+
runs.push(readSession(file, opts.now));
|
|
157
|
+
}
|
|
158
|
+
let subdirs = [];
|
|
159
|
+
try {
|
|
160
|
+
subdirs = fs.readdirSync(dir).map((d) => path.join(dir, d, "subagents"));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// not a directory listing we can read
|
|
164
|
+
}
|
|
165
|
+
for (const sub of subdirs) {
|
|
166
|
+
for (const file of listJsonl(sub)) {
|
|
167
|
+
if (fresh(file, opts.sinceMs))
|
|
168
|
+
runs.push(readSubagent(file, opts.now));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return runs;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
function readSession(file, now) {
|
|
176
|
+
const id = path.basename(file, ".jsonl");
|
|
177
|
+
const base = { id, runtime: "claude-code", kind: "session", title: id.slice(0, 8), state: "unknown", source: file };
|
|
178
|
+
try {
|
|
179
|
+
const head = firstPrompt(file);
|
|
180
|
+
const tail = readClaudeTail(readTailLines(file, TAIL_BYTES), "session");
|
|
181
|
+
const run = {
|
|
182
|
+
...base,
|
|
183
|
+
title: tail.title || (head.prompt ? oneLine(head.prompt, 60) : base.title),
|
|
184
|
+
cwd: tail.cwd || head.cwd,
|
|
185
|
+
startedAt: head.startedAt,
|
|
186
|
+
lastActivity: tail.lastActivity,
|
|
187
|
+
state: tail.state,
|
|
188
|
+
action: tail.action,
|
|
189
|
+
prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
|
|
190
|
+
activity: logActivity(file, now),
|
|
191
|
+
};
|
|
192
|
+
applyStall(run, tail.pendingTimeoutMs, now);
|
|
193
|
+
return run;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
return { ...base, error: `could not read: ${err.message}` };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function readSubagent(file, now) {
|
|
200
|
+
const id = path.basename(file, ".jsonl").replace(/^agent-/, "");
|
|
201
|
+
const parent = path.basename(path.dirname(path.dirname(file)));
|
|
202
|
+
const base = { id, runtime: "claude-code", kind: "subagent", parent, title: id.slice(0, 8), state: "unknown", source: file };
|
|
203
|
+
try {
|
|
204
|
+
const meta = readMeta(file);
|
|
205
|
+
const head = firstPrompt(file);
|
|
206
|
+
const tail = readClaudeTail(readTailLines(file, TAIL_BYTES), "subagent");
|
|
207
|
+
const run = {
|
|
208
|
+
...base,
|
|
209
|
+
title: str(meta.description) || (head.prompt ? oneLine(head.prompt, 60) : base.title),
|
|
210
|
+
model: str(meta.model),
|
|
211
|
+
cwd: tail.cwd || head.cwd,
|
|
212
|
+
startedAt: head.startedAt,
|
|
213
|
+
lastActivity: tail.lastActivity,
|
|
214
|
+
state: tail.state,
|
|
215
|
+
action: tail.action,
|
|
216
|
+
prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
|
|
217
|
+
activity: logActivity(file, now),
|
|
218
|
+
};
|
|
219
|
+
applyStall(run, tail.pendingTimeoutMs, now);
|
|
220
|
+
return run;
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
return { ...base, error: `could not read: ${err.message}` };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=claudeCode.js.map
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { applyStall } from "./claudeCode.js";
|
|
5
|
+
import { isObj, logActivity, oneLine, readHeadLines, readHeadText, readTailLines, str, textValues } from "./jsonl.js";
|
|
6
|
+
const HEAD_BYTES = 128 * 1024;
|
|
7
|
+
const TAIL_BYTES = 256 * 1024;
|
|
8
|
+
// prompts can sit behind several inline base64 images on one line; read this far only when needed
|
|
9
|
+
const RAW_HEAD_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
export function codexSessionsDir() {
|
|
11
|
+
if (process.env.PARAWATCH_CODEX_DIR)
|
|
12
|
+
return process.env.PARAWATCH_CODEX_DIR;
|
|
13
|
+
const base = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
14
|
+
return path.join(base, "sessions");
|
|
15
|
+
}
|
|
16
|
+
const PARENT_IN_CWD = /\/claude-\d+\/[^/]+\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/|$)/;
|
|
17
|
+
/** A Claude Code session id when Codex was started from inside that session's scratch space. */
|
|
18
|
+
export function parentFromCwd(cwd) {
|
|
19
|
+
return cwd ? PARENT_IN_CWD.exec(cwd)?.[1] : undefined;
|
|
20
|
+
}
|
|
21
|
+
function payload(r) {
|
|
22
|
+
return isObj(r) && isObj(r.payload) ? r.payload : undefined;
|
|
23
|
+
}
|
|
24
|
+
function messageText(p) {
|
|
25
|
+
const c = p.content;
|
|
26
|
+
if (!Array.isArray(c))
|
|
27
|
+
return "";
|
|
28
|
+
return c
|
|
29
|
+
.filter(isObj)
|
|
30
|
+
.map((b) => str(b.text) ?? "")
|
|
31
|
+
.join("\n")
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
function promptBlock(p) {
|
|
35
|
+
const c = p.content;
|
|
36
|
+
if (!Array.isArray(c))
|
|
37
|
+
return undefined;
|
|
38
|
+
for (const b of c.filter(isObj)) {
|
|
39
|
+
const t = (str(b.text) ?? "").trim();
|
|
40
|
+
if (!t || t.startsWith("<") || t.startsWith("# AGENTS.md instructions"))
|
|
41
|
+
continue;
|
|
42
|
+
return t;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function toolSummary(p) {
|
|
47
|
+
const name = str(p.name) ?? "tool";
|
|
48
|
+
let arg = str(p.input) ?? str(p.arguments) ?? "";
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(arg);
|
|
51
|
+
if (isObj(parsed))
|
|
52
|
+
arg = str(parsed.cmd) ?? str(parsed.command) ?? (Array.isArray(parsed.command) ? parsed.command.join(" ") : arg);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// free-form input
|
|
56
|
+
}
|
|
57
|
+
return arg ? `${name}: ${oneLine(arg, 80)}` : name;
|
|
58
|
+
}
|
|
59
|
+
/** The first real prompt in user messages whose lines were too long to parse (inline images). */
|
|
60
|
+
export function promptFromRaw(raw) {
|
|
61
|
+
for (const line of raw.split("\n")) {
|
|
62
|
+
if (!line.includes('"role":"user"'))
|
|
63
|
+
continue;
|
|
64
|
+
for (const t of textValues(line)) {
|
|
65
|
+
const v = t.trim();
|
|
66
|
+
if (v && !v.startsWith("<") && !v.startsWith("# AGENTS.md instructions"))
|
|
67
|
+
return v;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
export function readCodexLog(head, tail, rawHead) {
|
|
73
|
+
const meta = head.map(payload).find((p, i) => p && isObj(head[i]) && head[i].type === "session_meta");
|
|
74
|
+
const id = str(meta?.id) ?? str(meta?.session_id) ?? "unknown";
|
|
75
|
+
const cwd = str(meta?.cwd);
|
|
76
|
+
const exec = str(meta?.originator) === "codex_exec" || str(meta?.source) === "exec";
|
|
77
|
+
let prompt;
|
|
78
|
+
for (const r of head) {
|
|
79
|
+
const p = payload(r);
|
|
80
|
+
if (isObj(r) && r.type === "response_item" && p?.type === "message" && p.role === "user") {
|
|
81
|
+
// skip injected context: environment blocks, image tags, AGENTS.md instructions
|
|
82
|
+
const t = promptBlock(p);
|
|
83
|
+
if (t) {
|
|
84
|
+
prompt = t;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!prompt && rawHead)
|
|
90
|
+
prompt = promptFromRaw(rawHead());
|
|
91
|
+
const run = {
|
|
92
|
+
id,
|
|
93
|
+
runtime: "codex",
|
|
94
|
+
kind: exec ? "exec" : "session",
|
|
95
|
+
title: prompt ? oneLine(prompt, 60) : id.slice(0, 8),
|
|
96
|
+
cwd,
|
|
97
|
+
parent: parentFromCwd(cwd),
|
|
98
|
+
startedAt: str(meta?.timestamp) ?? (isObj(head[0]) ? str(head[0].timestamp) : undefined),
|
|
99
|
+
state: "working",
|
|
100
|
+
prompt: prompt ? oneLine(prompt, 400) : undefined,
|
|
101
|
+
};
|
|
102
|
+
let lastStart = -1;
|
|
103
|
+
let lastDone = -1;
|
|
104
|
+
let lastError = -1;
|
|
105
|
+
tail.forEach((r, i) => {
|
|
106
|
+
if (!isObj(r))
|
|
107
|
+
return;
|
|
108
|
+
const ts = str(r.timestamp);
|
|
109
|
+
if (ts && (!run.lastActivity || ts > run.lastActivity))
|
|
110
|
+
run.lastActivity = ts;
|
|
111
|
+
const p = payload(r);
|
|
112
|
+
if (!p)
|
|
113
|
+
return;
|
|
114
|
+
if (r.type === "event_msg" && p.type === "task_started")
|
|
115
|
+
lastStart = i;
|
|
116
|
+
if (r.type === "event_msg" && p.type === "task_complete") {
|
|
117
|
+
lastDone = i;
|
|
118
|
+
const msg = str(p.last_agent_message);
|
|
119
|
+
if (msg)
|
|
120
|
+
run.action = oneLine(msg, 100);
|
|
121
|
+
}
|
|
122
|
+
if (r.type === "event_msg" && (p.type === "error" || p.type === "turn_aborted"))
|
|
123
|
+
lastError = i;
|
|
124
|
+
if (r.type === "response_item" && (p.type === "custom_tool_call" || p.type === "function_call")) {
|
|
125
|
+
if (i > lastDone)
|
|
126
|
+
run.action = toolSummary(p);
|
|
127
|
+
}
|
|
128
|
+
if (r.type === "response_item" && p.type === "message" && p.role === "assistant" && i > lastDone) {
|
|
129
|
+
const t = messageText(p);
|
|
130
|
+
if (t)
|
|
131
|
+
run.action = oneLine(t, 100);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
if (lastError > Math.max(lastStart, lastDone))
|
|
135
|
+
run.state = "failed";
|
|
136
|
+
else if (lastDone > lastStart)
|
|
137
|
+
run.state = exec ? "done" : "idle";
|
|
138
|
+
return run;
|
|
139
|
+
}
|
|
140
|
+
function dayDirs(root, sinceMs, now) {
|
|
141
|
+
const out = [];
|
|
142
|
+
const day = 86_400_000;
|
|
143
|
+
for (let t = sinceMs - day; t <= now + day; t += day) {
|
|
144
|
+
const d = new Date(t);
|
|
145
|
+
const y = String(d.getFullYear());
|
|
146
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
147
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
148
|
+
const dir = path.join(root, y, m, dd);
|
|
149
|
+
if (!out.includes(dir))
|
|
150
|
+
out.push(dir);
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
export const codex = {
|
|
155
|
+
name: "codex",
|
|
156
|
+
scan(opts) {
|
|
157
|
+
const root = codexSessionsDir();
|
|
158
|
+
const runs = [];
|
|
159
|
+
for (const dir of dayDirs(root, opts.sinceMs, opts.now)) {
|
|
160
|
+
let files;
|
|
161
|
+
try {
|
|
162
|
+
files = fs.readdirSync(dir).filter((f) => f.startsWith("rollout-") && f.endsWith(".jsonl"));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
for (const f of files) {
|
|
168
|
+
const file = path.join(dir, f);
|
|
169
|
+
try {
|
|
170
|
+
if (fs.statSync(file).mtimeMs < opts.sinceMs)
|
|
171
|
+
continue;
|
|
172
|
+
const run = { ...readCodexLog(readHeadLines(file, HEAD_BYTES), readTailLines(file, TAIL_BYTES), () => readHeadText(file, RAW_HEAD_BYTES)), source: file };
|
|
173
|
+
run.activity = logActivity(file, opts.now);
|
|
174
|
+
applyStall(run, undefined, opts.now);
|
|
175
|
+
runs.push(run);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
runs.push({ id: f, runtime: "codex", kind: "exec", title: f, state: "unknown", source: file, error: `could not read: ${err.message}` });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return runs;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
//# sourceMappingURL=codex.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { claudeCode } from "./claudeCode.js";
|
|
2
|
+
import { codex } from "./codex.js";
|
|
3
|
+
export const adapters = [claudeCode, codex];
|
|
4
|
+
export function scanAll(opts, only) {
|
|
5
|
+
const runs = [];
|
|
6
|
+
for (const a of adapters) {
|
|
7
|
+
if (only && !only.includes(a.name))
|
|
8
|
+
continue;
|
|
9
|
+
try {
|
|
10
|
+
runs.push(...a.scan(opts));
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
runs.push({ id: `${a.name}-error`, runtime: a.name, kind: "session", title: `${a.name} adapter failed`, state: "unknown", error: err.message });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return runs;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=index.js.map
|