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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
/** Read up to `bytes` from the start of a file, dropping a torn last line. */
|
|
3
|
+
export function readHeadLines(file, bytes) {
|
|
4
|
+
const fd = fs.openSync(file, "r");
|
|
5
|
+
try {
|
|
6
|
+
const buf = Buffer.alloc(bytes);
|
|
7
|
+
const n = fs.readSync(fd, buf, 0, bytes, 0);
|
|
8
|
+
const text = buf.subarray(0, n).toString("utf8");
|
|
9
|
+
const lines = text.split("\n");
|
|
10
|
+
if (n === bytes)
|
|
11
|
+
lines.pop();
|
|
12
|
+
return parseLines(lines);
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
fs.closeSync(fd);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Read up to `bytes` from the end of a file, dropping a torn first line. Agent logs can be hundreds of MB. */
|
|
19
|
+
export function readTailLines(file, bytes) {
|
|
20
|
+
const size = fs.statSync(file).size;
|
|
21
|
+
const start = Math.max(0, size - bytes);
|
|
22
|
+
const fd = fs.openSync(file, "r");
|
|
23
|
+
try {
|
|
24
|
+
const len = size - start;
|
|
25
|
+
const buf = Buffer.alloc(len);
|
|
26
|
+
const n = fs.readSync(fd, buf, 0, len, start);
|
|
27
|
+
const lines = buf.subarray(0, n).toString("utf8").split("\n");
|
|
28
|
+
if (start > 0)
|
|
29
|
+
lines.shift();
|
|
30
|
+
return parseLines(lines);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
fs.closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Raw text from the start of a file, for lines too long to parse whole (inline images). */
|
|
37
|
+
export function readHeadText(file, bytes) {
|
|
38
|
+
const fd = fs.openSync(file, "r");
|
|
39
|
+
try {
|
|
40
|
+
const buf = Buffer.alloc(bytes);
|
|
41
|
+
const n = fs.readSync(fd, buf, 0, bytes, 0);
|
|
42
|
+
return buf.subarray(0, n).toString("utf8");
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
fs.closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Every JSON string value of a "text" key found in raw, possibly truncated, JSON text. */
|
|
49
|
+
export function textValues(raw) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const re = /"text":"((?:[^"\\]|\\.)*)"/g;
|
|
52
|
+
for (let m = re.exec(raw); m; m = re.exec(raw)) {
|
|
53
|
+
try {
|
|
54
|
+
out.push(JSON.parse(`"${m[1]}"`));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// malformed escape; skip
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function parseLines(lines) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (!line.trim())
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
out.push(JSON.parse(line));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// skip partial or foreign lines
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
export function oneLine(s, max = 100) {
|
|
77
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
78
|
+
return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
|
|
79
|
+
}
|
|
80
|
+
export function isObj(x) {
|
|
81
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
82
|
+
}
|
|
83
|
+
export function str(x) {
|
|
84
|
+
return typeof x === "string" ? x : undefined;
|
|
85
|
+
}
|
|
86
|
+
export const ACTIVITY_MINUTES = 60;
|
|
87
|
+
/** Which of the last `window` minutes saw any of these times: minutes before now, ascending. */
|
|
88
|
+
export function activeMinutes(times, now, window = ACTIVITY_MINUTES) {
|
|
89
|
+
const set = new Set();
|
|
90
|
+
for (const t of times) {
|
|
91
|
+
if (!Number.isFinite(t))
|
|
92
|
+
continue;
|
|
93
|
+
const m = Math.floor((now - t) / 60_000);
|
|
94
|
+
if (m >= 0 && m < window)
|
|
95
|
+
set.add(m);
|
|
96
|
+
}
|
|
97
|
+
return [...set].sort((a, b) => a - b);
|
|
98
|
+
}
|
|
99
|
+
const STAMP = /"timestamp":"([^"]{10,40})"/g;
|
|
100
|
+
/** Every record timestamp in raw log text, in milliseconds. */
|
|
101
|
+
export function timestampsIn(text) {
|
|
102
|
+
const out = [];
|
|
103
|
+
for (let m = STAMP.exec(text); m; m = STAMP.exec(text)) {
|
|
104
|
+
const t = Date.parse(m[1]);
|
|
105
|
+
if (Number.isFinite(t))
|
|
106
|
+
out.push(t);
|
|
107
|
+
}
|
|
108
|
+
STAMP.lastIndex = 0;
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
// How far back the first look at a log goes. Later looks read only what was appended since.
|
|
112
|
+
const FIRST_LOOK_BYTES = 16 * 1024 * 1024;
|
|
113
|
+
const seen = new Map();
|
|
114
|
+
/**
|
|
115
|
+
* The active minutes of an append-only log. A long-running process (the desktop app) keeps what
|
|
116
|
+
* it has read, so each refresh reads only the new end of the file.
|
|
117
|
+
*/
|
|
118
|
+
export function logActivity(file, now) {
|
|
119
|
+
const size = fs.statSync(file).size;
|
|
120
|
+
let entry = seen.get(file);
|
|
121
|
+
if (!entry || size < entry.offset)
|
|
122
|
+
entry = { offset: Math.max(0, size - FIRST_LOOK_BYTES), times: [] };
|
|
123
|
+
if (size > entry.offset) {
|
|
124
|
+
const fd = fs.openSync(file, "r");
|
|
125
|
+
try {
|
|
126
|
+
const len = size - entry.offset;
|
|
127
|
+
const buf = Buffer.alloc(len);
|
|
128
|
+
const n = fs.readSync(fd, buf, 0, len, entry.offset);
|
|
129
|
+
const text = buf.subarray(0, n).toString("utf8");
|
|
130
|
+
// stop at the last full line so a record being written is read next time
|
|
131
|
+
const end = text.lastIndexOf("\n") + 1;
|
|
132
|
+
entry.times.push(...timestampsIn(text.slice(0, end)));
|
|
133
|
+
entry.offset += Buffer.byteLength(text.slice(0, end), "utf8");
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
fs.closeSync(fd);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const horizon = now - ACTIVITY_MINUTES * 60_000;
|
|
140
|
+
entry.times = entry.times.filter((t) => t >= horizon);
|
|
141
|
+
seen.set(file, entry);
|
|
142
|
+
return activeMinutes(entry.times, now);
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=jsonl.js.map
|
package/dist/bin/cli.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { UserError, addProject, answerCard, claimSession, closeCard, commentCard, consumerName, editCard, endRun, noteRun, openCard, readInbox, removeProject, startRun, waitInbox, } from "../core/ops.js";
|
|
6
|
+
import { readEvents } from "../core/store.js";
|
|
7
|
+
import { followEvents } from "../core/follow.js";
|
|
8
|
+
import { fold } from "../core/state.js";
|
|
9
|
+
import { collectRuns } from "../core/runs.js";
|
|
10
|
+
import { cardDetail, cardLine, cardRows, inboxLine, runsTree, view } from "../core/format.js";
|
|
11
|
+
import { hookContext, notify, writeInstructions } from "../core/integrate.js";
|
|
12
|
+
import { installSkill, skillSource, skillTargets } from "../core/skill.js";
|
|
13
|
+
import { cardContextLine, resolveGoto } from "../core/desktop.js";
|
|
14
|
+
import { snapshot } from "../core/snapshot.js";
|
|
15
|
+
import { SCHEMA_VERSION } from "../core/types.js";
|
|
16
|
+
const collect = (v, prev = []) => [...prev, v];
|
|
17
|
+
const int = (v) => {
|
|
18
|
+
const n = Number(v);
|
|
19
|
+
if (!Number.isInteger(n))
|
|
20
|
+
throw new UserError(`"${v}" is not a whole number`);
|
|
21
|
+
return n;
|
|
22
|
+
};
|
|
23
|
+
const print = (x) => process.stdout.write(JSON.stringify(x) + "\n");
|
|
24
|
+
/** The agent session running this command, when its runtime says so. */
|
|
25
|
+
function currentSession() {
|
|
26
|
+
return process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID || undefined;
|
|
27
|
+
}
|
|
28
|
+
const program = new Command();
|
|
29
|
+
program
|
|
30
|
+
.name("parawatch")
|
|
31
|
+
.description("See what your agents are doing, what needs you, and wake the right agent when you answer.")
|
|
32
|
+
.version("0.1.0");
|
|
33
|
+
// ------------------------------------------------------------------ cards
|
|
34
|
+
function cardCommand(kind, name, summary) {
|
|
35
|
+
return program
|
|
36
|
+
.command(`${name} <title>`)
|
|
37
|
+
.description(summary)
|
|
38
|
+
.requiredOption("-p, --project <name>", "project this card belongs to")
|
|
39
|
+
.option("-t, --task <task>", "task within the project")
|
|
40
|
+
.option("-b, --body <text>", "two or three lines of context")
|
|
41
|
+
.option("--attach <file>", "image, video or file to look at (repeatable)", collect)
|
|
42
|
+
.option("--from <who>", "who is asking (defaults to the calling session's claimed title)")
|
|
43
|
+
.option("--session <id>", "the agent session asking (defaults to the calling session)")
|
|
44
|
+
.option("--since <date>", "when the matter first came up, if earlier (YYYY-MM-DD)")
|
|
45
|
+
.option("--json", "print the card as JSON");
|
|
46
|
+
}
|
|
47
|
+
/** Options every card kind shares. */
|
|
48
|
+
function common(o) {
|
|
49
|
+
return {
|
|
50
|
+
project: o.project,
|
|
51
|
+
task: o.task,
|
|
52
|
+
body: o.body,
|
|
53
|
+
attach: o.attach,
|
|
54
|
+
from: o.from,
|
|
55
|
+
session: o.session ?? currentSession(),
|
|
56
|
+
since: o.since,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function finishCard(input, json) {
|
|
60
|
+
const card = openCard(input);
|
|
61
|
+
notify(card);
|
|
62
|
+
if (json)
|
|
63
|
+
print(card);
|
|
64
|
+
else
|
|
65
|
+
process.stdout.write(`${card.id}\n`);
|
|
66
|
+
}
|
|
67
|
+
cardCommand("decide", "ask", "ask the human to decide")
|
|
68
|
+
.option("-o, --option <text>", "a choice (repeatable)", collect)
|
|
69
|
+
.option("-r, --recommend <n>", "the recommended choice, 1-based", int)
|
|
70
|
+
.action((title, o) => finishCard({ kind: "decide", title, ...common(o), options: o.option, recommend: o.recommend }, o.json));
|
|
71
|
+
cardCommand("try", "try", "ask the human to try a build, watch a video or look at a screen")
|
|
72
|
+
.option("--open <path>", "app, file or URL to open (repeatable)", collect)
|
|
73
|
+
.option("-l, --look <text>", "what to look at")
|
|
74
|
+
.action((title, o) => finishCard({ kind: "try", title, ...common(o), open: o.open, look: o.look }, o.json));
|
|
75
|
+
cardCommand("block", "block", "something only the human can do, and you are stuck until they do it")
|
|
76
|
+
.action((title, o) => finishCard({ kind: "block", title, ...common(o) }, o.json));
|
|
77
|
+
cardCommand("todo", "todo", "something only the human can do, but nothing waits on it now")
|
|
78
|
+
.action((title, o) => finishCard({ kind: "todo", title, ...common(o) }, o.json));
|
|
79
|
+
program
|
|
80
|
+
.command("edit <card>")
|
|
81
|
+
.description("change a card: its kind, wording, task, who asked or when it came up (an empty value clears)")
|
|
82
|
+
.option("-k, --kind <kind>", "decide, try, block or todo")
|
|
83
|
+
.option("--title <text>", "new title")
|
|
84
|
+
.option("-b, --body <text>", "new body")
|
|
85
|
+
.option("-t, --task <task>", "task within the project")
|
|
86
|
+
.option("--from <who>", "who asked")
|
|
87
|
+
.option("--session <id>", "the agent session that asked")
|
|
88
|
+
.option("--since <date>", "when the matter first came up (YYYY-MM-DD)")
|
|
89
|
+
.option("--json", "print the card as JSON")
|
|
90
|
+
.action((id, o) => {
|
|
91
|
+
const card = editCard(id, { kind: o.kind, title: o.title, body: o.body, task: o.task, from: o.from, session: o.session, since: o.since });
|
|
92
|
+
if (o.json)
|
|
93
|
+
print(card);
|
|
94
|
+
else
|
|
95
|
+
process.stdout.write(cardLine(card) + "\n");
|
|
96
|
+
});
|
|
97
|
+
program
|
|
98
|
+
.command("goto <target>")
|
|
99
|
+
.description("open the agent session behind a card (or a session id) in the Claude desktop app")
|
|
100
|
+
.option("--copy", "also put a line about the card on the clipboard, to paste into that session")
|
|
101
|
+
.option("--print", "print the link instead of opening it")
|
|
102
|
+
.action((target, o) => {
|
|
103
|
+
const g = resolveGoto(target, fold(readEvents()));
|
|
104
|
+
if (o.copy && g.card) {
|
|
105
|
+
const line = cardContextLine(g.card);
|
|
106
|
+
try {
|
|
107
|
+
execFileSync("pbcopy", { input: line });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
process.stderr.write(`parawatch: could not copy to the clipboard; the line is: ${line}\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!g.url)
|
|
114
|
+
throw new UserError(`${g.name} is not a Claude desktop session. In a terminal: claude --resume ${g.session}`);
|
|
115
|
+
if (o.print) {
|
|
116
|
+
process.stdout.write(g.url + "\n");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
execFileSync("open", [g.url]);
|
|
120
|
+
process.stdout.write(`Opened ${g.name}${g.desktop?.archived ? " (archived)" : ""}\n`);
|
|
121
|
+
});
|
|
122
|
+
program
|
|
123
|
+
.command("answer <card>")
|
|
124
|
+
.description("answer a card (this is what wakes the waiting agent)")
|
|
125
|
+
.option("-c, --choice <n>", "chosen option, 1-based", int)
|
|
126
|
+
.option("-m, --comment <text>", "free text")
|
|
127
|
+
.option("--attach <file>", "a screenshot or file (repeatable)", collect)
|
|
128
|
+
.option("--by <who>", "who answers", "human")
|
|
129
|
+
.action((id, o) => answerCard(id, { choice: o.choice, comment: o.comment, attach: o.attach, by: o.by }));
|
|
130
|
+
program
|
|
131
|
+
.command("comment <card> <text>")
|
|
132
|
+
.description("add a comment to a card")
|
|
133
|
+
.option("--by <who>", "who comments (use 'human' to wake the agent)", "agent")
|
|
134
|
+
.action((id, text, o) => commentCard(id, text, o.by));
|
|
135
|
+
program
|
|
136
|
+
.command("close <card>")
|
|
137
|
+
.description("close a card once it has been acted on")
|
|
138
|
+
.option("-n, --note <text>", "what was done")
|
|
139
|
+
.action((id, o) => closeCard(id, o.note));
|
|
140
|
+
program
|
|
141
|
+
.command("show <card>")
|
|
142
|
+
.description("show one card with its answers")
|
|
143
|
+
.option("--json", "JSON output")
|
|
144
|
+
.action((id, o) => {
|
|
145
|
+
const card = fold(readEvents()).cards.get(id);
|
|
146
|
+
if (!card)
|
|
147
|
+
throw new UserError(`no card "${id}"`);
|
|
148
|
+
if (o.json)
|
|
149
|
+
print(card);
|
|
150
|
+
else
|
|
151
|
+
process.stdout.write(cardDetail(card) + "\n");
|
|
152
|
+
});
|
|
153
|
+
program
|
|
154
|
+
.command("cards")
|
|
155
|
+
.description("list cards (open and answered by default)")
|
|
156
|
+
.option("-p, --project <name>", "only this project")
|
|
157
|
+
.option("-a, --all", "include closed cards")
|
|
158
|
+
.option("--json", "JSON output")
|
|
159
|
+
.action((o) => {
|
|
160
|
+
const cards = [...fold(readEvents()).cards.values()].filter((c) => (o.all || c.status !== "closed") && (!o.project || c.project === o.project));
|
|
161
|
+
if (o.json)
|
|
162
|
+
print({ v: SCHEMA_VERSION, cards });
|
|
163
|
+
else
|
|
164
|
+
process.stdout.write(cards.length ? cardRows(cards, view()).join("\n") + "\n" : "No cards.\n");
|
|
165
|
+
});
|
|
166
|
+
// ------------------------------------------------------------------ inbox
|
|
167
|
+
function inboxOptions(c) {
|
|
168
|
+
return c
|
|
169
|
+
.option("-p, --project <name>", "only answers for this project")
|
|
170
|
+
.option("--card <id>", "only answers to this card")
|
|
171
|
+
.option("--as <consumer>", "cursor name (defaults to the project)")
|
|
172
|
+
.option("--json", "JSON output");
|
|
173
|
+
}
|
|
174
|
+
inboxOptions(program.command("wait").description("block until the human answers, print the answers and exit"))
|
|
175
|
+
.option("--timeout <seconds>", "give up after this long (exit code 2); 0 waits forever", int, 0)
|
|
176
|
+
.action(async (o) => {
|
|
177
|
+
const items = await waitInbox(consumerName(o), { project: o.project, card: o.card }, o.timeout * 1000);
|
|
178
|
+
if (!items.length) {
|
|
179
|
+
process.exitCode = 2;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (o.json)
|
|
183
|
+
print({ v: SCHEMA_VERSION, items });
|
|
184
|
+
else
|
|
185
|
+
process.stdout.write(items.map(inboxLine).join("\n") + "\n");
|
|
186
|
+
});
|
|
187
|
+
inboxOptions(program.command("inbox").description("print unhandled answers without waiting"))
|
|
188
|
+
.option("--peek", "do not mark them as seen")
|
|
189
|
+
.action((o) => {
|
|
190
|
+
const items = readInbox(consumerName(o), { project: o.project, card: o.card }, !o.peek);
|
|
191
|
+
if (o.json)
|
|
192
|
+
print({ v: SCHEMA_VERSION, items });
|
|
193
|
+
else if (items.length)
|
|
194
|
+
process.stdout.write(items.map(inboxLine).join("\n") + "\n");
|
|
195
|
+
});
|
|
196
|
+
program
|
|
197
|
+
.command("watch")
|
|
198
|
+
.description("stream every new event as one JSON line (for plugins and monitors)")
|
|
199
|
+
.option("--from <seq>", "replay events after this sequence number first", int)
|
|
200
|
+
.action(async (o) => {
|
|
201
|
+
followEvents(print, o.from);
|
|
202
|
+
await new Promise(() => { });
|
|
203
|
+
});
|
|
204
|
+
// ------------------------------------------------------------------ runs
|
|
205
|
+
program
|
|
206
|
+
.command("runs")
|
|
207
|
+
.description("agents seen in their logs, grouped by project")
|
|
208
|
+
.option("-p, --project <name>", "only this project")
|
|
209
|
+
.option("--since <hours>", "look at logs touched in the last N hours", int, 6)
|
|
210
|
+
.option("-a, --all", "include runs that ended a while ago")
|
|
211
|
+
.option("--runtime <name>", "only this adapter (repeatable)", collect)
|
|
212
|
+
.option("-w, --wide", "show runtime and ids")
|
|
213
|
+
.option("--json", "JSON output")
|
|
214
|
+
.action((o) => {
|
|
215
|
+
const runs = collectRuns(fold(readEvents()), { sinceHours: o.since, project: o.project, includeEnded: o.all, runtimes: o.runtime });
|
|
216
|
+
if (o.json)
|
|
217
|
+
print({ v: SCHEMA_VERSION, runs });
|
|
218
|
+
else
|
|
219
|
+
process.stdout.write((runs.length ? runsTree(runs, view({ wide: o.wide, all: o.all })) : "Nothing running.") + "\n");
|
|
220
|
+
});
|
|
221
|
+
const run = program.command("run").description("report a job that leaves no agent log");
|
|
222
|
+
run
|
|
223
|
+
.command("start <title>")
|
|
224
|
+
.requiredOption("-p, --project <name>", "project")
|
|
225
|
+
.option("-t, --task <task>", "task")
|
|
226
|
+
.option("--parent <id>", "the run that started this one (defaults to the calling session)")
|
|
227
|
+
.action((title, o) => {
|
|
228
|
+
process.stdout.write(startRun({ title, project: o.project, task: o.task, parent: o.parent ?? currentSession() }) + "\n");
|
|
229
|
+
});
|
|
230
|
+
run.command("note <id> <text>").action((id, text) => noteRun(id, text));
|
|
231
|
+
run
|
|
232
|
+
.command("end <id>")
|
|
233
|
+
.option("--failed", "it failed")
|
|
234
|
+
.option("-n, --note <text>", "how it ended")
|
|
235
|
+
.action((id, o) => endRun(id, o.failed ? "failed" : "done", o.note));
|
|
236
|
+
program
|
|
237
|
+
.command("claim [session]")
|
|
238
|
+
.description("say which project an agent session belongs to (id prefix, 6+ characters; defaults to the calling session)")
|
|
239
|
+
.requiredOption("-p, --project <name>", "project")
|
|
240
|
+
.option("-t, --task <task>", "task")
|
|
241
|
+
.option("--role <role>", "for example lead or worker")
|
|
242
|
+
.option("--title <title>", "name to show")
|
|
243
|
+
.action((session, o) => {
|
|
244
|
+
const id = session ?? currentSession();
|
|
245
|
+
if (!id)
|
|
246
|
+
throw new UserError("no session id given and none found in the environment (CLAUDE_CODE_SESSION_ID, CODEX_THREAD_ID)");
|
|
247
|
+
claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title });
|
|
248
|
+
process.stdout.write(`${id.slice(0, 8)} → ${o.project}${o.role ? ` (${o.role})` : ""}\n`);
|
|
249
|
+
});
|
|
250
|
+
// ------------------------------------------------------------------ status
|
|
251
|
+
program
|
|
252
|
+
.command("status")
|
|
253
|
+
.description("everything at a glance: what needs you, then what is running")
|
|
254
|
+
.option("-p, --project <name>", "only this project")
|
|
255
|
+
.option("--since <hours>", "look at logs touched in the last N hours", int, 6)
|
|
256
|
+
.option("-w, --wide", "show runtime and ids")
|
|
257
|
+
.option("--json", "JSON output")
|
|
258
|
+
.action((o) => {
|
|
259
|
+
const snap = snapshot({ sinceHours: o.since, project: o.project });
|
|
260
|
+
if (o.json) {
|
|
261
|
+
print(snap);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const { cards, runs } = snap;
|
|
265
|
+
const v = view({ wide: o.wide });
|
|
266
|
+
const head = (title, n, ...extra) => v.color ? `\x1b[1m${title}\x1b[0m \x1b[2m${n}${extra.join("")}\x1b[0m` : `${title} ${n}${extra.join("")}`;
|
|
267
|
+
const open = cards.filter((c) => c.status === "open" && c.kind !== "todo");
|
|
268
|
+
const todo = cards.filter((c) => c.status === "open" && c.kind === "todo");
|
|
269
|
+
const answered = cards.filter((c) => c.status === "answered");
|
|
270
|
+
const out = [];
|
|
271
|
+
out.push(head("NEEDS YOU", open.length || "nothing"));
|
|
272
|
+
out.push(...cardRows(open, v, Date.now(), " "));
|
|
273
|
+
if (todo.length) {
|
|
274
|
+
out.push("", head("TO DO, NOTHING WAITS ON IT", todo.length));
|
|
275
|
+
out.push(...cardRows(todo, v, Date.now(), " "));
|
|
276
|
+
}
|
|
277
|
+
if (answered.length) {
|
|
278
|
+
out.push("", head("ANSWERED, WAITING FOR THE AGENT", answered.length));
|
|
279
|
+
out.push(...cardRows(answered, v, Date.now(), " "));
|
|
280
|
+
}
|
|
281
|
+
const working = runs.filter((r) => r.state === "working" || r.state === "waiting").length;
|
|
282
|
+
out.push("", head("AGENTS", runs.length ? `${working} working` : "nothing running"));
|
|
283
|
+
if (runs.length)
|
|
284
|
+
out.push(runsTree(runs, v));
|
|
285
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
286
|
+
});
|
|
287
|
+
// ------------------------------------------------------------------ skill
|
|
288
|
+
const skill = program.command("skill").description("the agent skill that teaches agents to use parawatch");
|
|
289
|
+
skill
|
|
290
|
+
.command("path")
|
|
291
|
+
.description("print where the skill lives")
|
|
292
|
+
.action(() => {
|
|
293
|
+
process.stdout.write(skillSource() + "\n");
|
|
294
|
+
});
|
|
295
|
+
skill
|
|
296
|
+
.command("install")
|
|
297
|
+
.description("link the skill into Claude Code (~/.claude/skills) and Codex (~/.codex/skills)")
|
|
298
|
+
.option("--runtime <name>", "only this runtime: claude-code or codex (repeatable)", collect)
|
|
299
|
+
.option("--force", "replace a folder that is already there")
|
|
300
|
+
.action((o) => {
|
|
301
|
+
for (const r of installSkill(skillTargets(), { only: o.runtime, force: o.force })) {
|
|
302
|
+
process.stdout.write(`${r.runtime.padEnd(12)} ${r.result.padEnd(15)} ${r.path}${r.note ? ` (${r.note})` : ""}\n`);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
// ------------------------------------------------------------------ setup
|
|
306
|
+
const project = program.command("project").description("projects you work on");
|
|
307
|
+
project
|
|
308
|
+
.command("add <name> [paths...]")
|
|
309
|
+
.description("register a project and the checkouts or folders it lives in")
|
|
310
|
+
.action((name, paths) => addProject(name, paths.length ? paths : [process.cwd()]));
|
|
311
|
+
project.command("remove <name>").action((name) => removeProject(name));
|
|
312
|
+
project
|
|
313
|
+
.command("list")
|
|
314
|
+
.option("--json", "JSON output")
|
|
315
|
+
.action((o) => {
|
|
316
|
+
const ps = [...fold(readEvents()).projects.values()];
|
|
317
|
+
if (o.json)
|
|
318
|
+
print({ v: SCHEMA_VERSION, projects: ps });
|
|
319
|
+
else
|
|
320
|
+
process.stdout.write(ps.map((p) => `${p.name} ${p.paths.join(", ")}`).join("\n") + (ps.length ? "\n" : "No projects.\n"));
|
|
321
|
+
});
|
|
322
|
+
program
|
|
323
|
+
.command("init")
|
|
324
|
+
.description("add parawatch instructions for agents to an instruction file")
|
|
325
|
+
.option("-f, --file <path>", "file to write", "AGENTS.md")
|
|
326
|
+
.option("-p, --project <name>", "project name to put in the examples")
|
|
327
|
+
.action((o) => {
|
|
328
|
+
const changed = writeInstructions(o.file, o.project);
|
|
329
|
+
process.stdout.write(changed ? `Updated ${o.file}\n` : `${o.file} already up to date\n`);
|
|
330
|
+
});
|
|
331
|
+
program
|
|
332
|
+
.command("hook <runtime>")
|
|
333
|
+
.description("hook entry point; 'claude-code' reads the hook JSON on stdin")
|
|
334
|
+
.option("-p, --project <name>", "project, when it cannot be told from the session")
|
|
335
|
+
.action((runtime, o) => {
|
|
336
|
+
if (runtime !== "claude-code")
|
|
337
|
+
throw new UserError(`no hook for "${runtime}"`);
|
|
338
|
+
let input = {};
|
|
339
|
+
try {
|
|
340
|
+
input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
input = {};
|
|
344
|
+
}
|
|
345
|
+
const text = hookContext(input, o.project);
|
|
346
|
+
if (!text)
|
|
347
|
+
return;
|
|
348
|
+
if (input.hook_event_name)
|
|
349
|
+
print({ hookSpecificOutput: { hookEventName: input.hook_event_name, additionalContext: text } });
|
|
350
|
+
else
|
|
351
|
+
process.stdout.write(text + "\n");
|
|
352
|
+
});
|
|
353
|
+
program.parseAsync().catch((err) => {
|
|
354
|
+
if (err instanceof UserError) {
|
|
355
|
+
process.stderr.write(`parawatch: ${err.message}\n`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
throw err;
|
|
359
|
+
});
|
|
360
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { sessionTitle, UserError } from "./ops.js";
|
|
5
|
+
/**
|
|
6
|
+
* Where the Claude desktop app keeps its Code sessions: one JSON file per session, named
|
|
7
|
+
* `local_<id>.json`, holding the CLI session id the app runs (`cliSessionId`).
|
|
8
|
+
*/
|
|
9
|
+
export function desktopSessionsDir() {
|
|
10
|
+
return (process.env.PARAWATCH_CLAUDE_DESKTOP_DIR ||
|
|
11
|
+
path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code-sessions"));
|
|
12
|
+
}
|
|
13
|
+
function* sessionFiles(dir, depth = 0) {
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const e of entries) {
|
|
22
|
+
const p = path.join(dir, e.name);
|
|
23
|
+
if (e.isDirectory() && depth < 3)
|
|
24
|
+
yield* sessionFiles(p, depth + 1);
|
|
25
|
+
else if (e.isFile() && /^local_[A-Za-z0-9-]+\.json$/.test(e.name))
|
|
26
|
+
yield p;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** The desktop app session running a CLI session (full id or a prefix of 6+ characters). */
|
|
30
|
+
export function findDesktopSession(cliSession, dir = desktopSessionsDir()) {
|
|
31
|
+
if (cliSession.length < 6)
|
|
32
|
+
return undefined;
|
|
33
|
+
let best;
|
|
34
|
+
for (const file of sessionFiles(dir)) {
|
|
35
|
+
let d;
|
|
36
|
+
try {
|
|
37
|
+
d = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const ids = [d.cliSessionId, d.lastAssistantCliSessionId].filter((x) => typeof x === "string");
|
|
43
|
+
if (!ids.some((x) => x.startsWith(cliSession)))
|
|
44
|
+
continue;
|
|
45
|
+
const s = {
|
|
46
|
+
id: typeof d.sessionId === "string" ? d.sessionId : path.basename(file, ".json"),
|
|
47
|
+
cliSessionId: ids[0],
|
|
48
|
+
title: typeof d.title === "string" ? d.title : undefined,
|
|
49
|
+
cwd: typeof d.cwd === "string" ? d.cwd : undefined,
|
|
50
|
+
archived: d.isArchived === true,
|
|
51
|
+
lastActivityAt: typeof d.lastActivityAt === "number" ? d.lastActivityAt : 0,
|
|
52
|
+
};
|
|
53
|
+
// prefer a live session, then the most recent one
|
|
54
|
+
if (!best || (best.archived && !s.archived) || (best.archived === s.archived && s.lastActivityAt > best.lastActivityAt))
|
|
55
|
+
best = s;
|
|
56
|
+
}
|
|
57
|
+
return best;
|
|
58
|
+
}
|
|
59
|
+
/** A link that opens the session in the Claude desktop app. */
|
|
60
|
+
export function desktopSessionUrl(s) {
|
|
61
|
+
return `claude://code/continue?session=${encodeURIComponent(s.id)}&source=parawatch`;
|
|
62
|
+
}
|
|
63
|
+
/** Where `goto` leads for a card id or a session id (prefix). */
|
|
64
|
+
export function resolveGoto(target, state) {
|
|
65
|
+
const card = state.cards.get(target);
|
|
66
|
+
const session = card ? card.session : target;
|
|
67
|
+
if (!session) {
|
|
68
|
+
throw new UserError(`card ${target} does not say which session opened it (set one with: parawatch edit ${target} --session <id>)`);
|
|
69
|
+
}
|
|
70
|
+
const desktop = findDesktopSession(session);
|
|
71
|
+
const name = sessionTitle(state.claims, session) ?? desktop?.title ?? session.slice(0, 8);
|
|
72
|
+
return { name, session, card, desktop, url: desktop ? desktopSessionUrl(desktop) : undefined };
|
|
73
|
+
}
|
|
74
|
+
/** A line to paste into the session so it knows which card the human means. */
|
|
75
|
+
export function cardContextLine(card) {
|
|
76
|
+
return `About parawatch card ${card.id} ("${card.title}"): `;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=desktop.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { eventsPath, homeDir, parseEventLines } from "./store.js";
|
|
3
|
+
/**
|
|
4
|
+
* Call `onEvent` for every event appended after `afterSeq` (or after the current end when it is
|
|
5
|
+
* undefined). Watches the store folder and also checks once a second. Returns a stop function.
|
|
6
|
+
*/
|
|
7
|
+
export function followEvents(onEvent, afterSeq) {
|
|
8
|
+
const file = eventsPath();
|
|
9
|
+
let offset = 0;
|
|
10
|
+
let lastSeq = afterSeq ?? Infinity;
|
|
11
|
+
try {
|
|
12
|
+
const text = fs.readFileSync(file, "utf8");
|
|
13
|
+
offset = Buffer.byteLength(text);
|
|
14
|
+
const events = parseEventLines(text);
|
|
15
|
+
const end = events[events.length - 1]?.seq ?? 0;
|
|
16
|
+
if (afterSeq === undefined)
|
|
17
|
+
lastSeq = end;
|
|
18
|
+
else {
|
|
19
|
+
for (const ev of events)
|
|
20
|
+
if (ev.seq > afterSeq)
|
|
21
|
+
onEvent(ev);
|
|
22
|
+
lastSeq = Math.max(afterSeq, end);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
lastSeq = afterSeq ?? 0;
|
|
27
|
+
}
|
|
28
|
+
const pump = () => {
|
|
29
|
+
let size = 0;
|
|
30
|
+
try {
|
|
31
|
+
size = fs.statSync(file).size;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (size < offset)
|
|
37
|
+
offset = 0; // file replaced
|
|
38
|
+
if (size === offset)
|
|
39
|
+
return;
|
|
40
|
+
const fd = fs.openSync(file, "r");
|
|
41
|
+
const buf = Buffer.alloc(size - offset);
|
|
42
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
43
|
+
fs.closeSync(fd);
|
|
44
|
+
const text = buf.toString("utf8");
|
|
45
|
+
const cut = text.lastIndexOf("\n");
|
|
46
|
+
if (cut < 0)
|
|
47
|
+
return;
|
|
48
|
+
offset += Buffer.byteLength(text.slice(0, cut + 1));
|
|
49
|
+
for (const ev of parseEventLines(text.slice(0, cut + 1))) {
|
|
50
|
+
if (ev.seq > lastSeq) {
|
|
51
|
+
onEvent(ev);
|
|
52
|
+
lastSeq = ev.seq;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
let watcher;
|
|
57
|
+
try {
|
|
58
|
+
fs.mkdirSync(homeDir(), { recursive: true });
|
|
59
|
+
watcher = fs.watch(homeDir(), () => pump());
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
watcher = undefined; // the timer alone still works
|
|
63
|
+
}
|
|
64
|
+
const timer = setInterval(pump, 1000);
|
|
65
|
+
return () => {
|
|
66
|
+
clearInterval(timer);
|
|
67
|
+
watcher?.close();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=follow.js.map
|