parawatch 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -24
- package/dist/adapters/claudeCode.js +109 -4
- package/dist/bin/cli.js +153 -22
- package/dist/core/desktop.js +26 -2
- package/dist/core/digest.js +146 -0
- package/dist/core/format.js +97 -7
- package/dist/core/github.js +442 -0
- package/dist/core/integrate.js +11 -4
- package/dist/core/ops.js +151 -7
- package/dist/core/runs.js +148 -24
- package/dist/core/snapshot.js +50 -3
- package/dist/core/state.js +14 -2
- package/dist/core/store.js +11 -12
- package/dist/core/types.js +1 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +54 -24
package/dist/core/ops.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { appendEvent, eventsPath, readCursor, readEvents, writeCursor } from "./store.js";
|
|
3
|
+
import { appendEvent, eventsPath, homeDir, readCursor, readEvents, writeCursor } from "./store.js";
|
|
4
4
|
import { fold, nextId } from "./state.js";
|
|
5
|
-
import { CARD_KINDS, HUMAN } from "./types.js";
|
|
5
|
+
import { CARD_KINDS, HUMAN, PR_CHANGES } from "./types.js";
|
|
6
6
|
export class UserError extends Error {
|
|
7
7
|
}
|
|
8
8
|
function cleanList(xs) {
|
|
@@ -27,6 +27,19 @@ export function removeProject(name) {
|
|
|
27
27
|
throw new UserError(`no project named "${name}"`);
|
|
28
28
|
appendEvent(() => ({ type: "project.remove", name }));
|
|
29
29
|
}
|
|
30
|
+
const PREVIEW_MAX = 20_000;
|
|
31
|
+
function readPreview(file) {
|
|
32
|
+
if (!file?.trim())
|
|
33
|
+
return undefined;
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = fs.readFileSync(path.resolve(file), "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new UserError(`cannot read the preview file ${file}`);
|
|
40
|
+
}
|
|
41
|
+
return text.length > PREVIEW_MAX ? text.slice(0, PREVIEW_MAX) + "\n…" : text;
|
|
42
|
+
}
|
|
30
43
|
/** A date or timestamp as ISO text; a bare date counts as local midnight. */
|
|
31
44
|
export function parseWhen(s) {
|
|
32
45
|
const t = s.trim();
|
|
@@ -52,7 +65,10 @@ export function openCard(input) {
|
|
|
52
65
|
throw new UserError("a card needs a title");
|
|
53
66
|
if (!input.project.trim())
|
|
54
67
|
throw new UserError("a card needs --project");
|
|
55
|
-
const
|
|
68
|
+
const action = input.action?.trim() || undefined;
|
|
69
|
+
// an approval with no choices of its own is a yes or a no
|
|
70
|
+
const options = cleanList(input.options) ?? (action ? ["Do it", "Don't"] : undefined);
|
|
71
|
+
const preview = readPreview(input.preview);
|
|
56
72
|
if (input.recommend !== undefined) {
|
|
57
73
|
if (!options)
|
|
58
74
|
throw new UserError("--recommend needs --option");
|
|
@@ -84,6 +100,8 @@ export function openCard(input) {
|
|
|
84
100
|
from,
|
|
85
101
|
session,
|
|
86
102
|
since,
|
|
103
|
+
action,
|
|
104
|
+
preview,
|
|
87
105
|
},
|
|
88
106
|
};
|
|
89
107
|
});
|
|
@@ -160,10 +178,13 @@ export function closeCard(id, note) {
|
|
|
160
178
|
appendEvent(() => ({ type: "card.close", card: id, note: note?.trim() || undefined }));
|
|
161
179
|
}
|
|
162
180
|
export function startRun(r) {
|
|
181
|
+
if (r.heartbeat !== undefined && (!Number.isFinite(r.heartbeat) || r.heartbeat <= 0))
|
|
182
|
+
throw new UserError("--heartbeat must be a number of seconds above 0");
|
|
183
|
+
const log = r.log?.trim() ? path.resolve(r.log) : undefined;
|
|
163
184
|
let id = "";
|
|
164
185
|
appendEvent((events) => {
|
|
165
186
|
id = nextId(events, "r");
|
|
166
|
-
return { type: "run.start", run: { id, project: r.project, task: r.task, title: r.title, parent: r.parent } };
|
|
187
|
+
return { type: "run.start", run: { id, project: r.project, task: r.task, title: r.title, parent: r.parent, heartbeat: r.heartbeat, log } };
|
|
167
188
|
});
|
|
168
189
|
return id;
|
|
169
190
|
}
|
|
@@ -172,23 +193,82 @@ export function noteRun(id, text) {
|
|
|
172
193
|
throw new UserError(`no declared run "${id}"`);
|
|
173
194
|
appendEvent(() => ({ type: "run.note", run: id, text }));
|
|
174
195
|
}
|
|
196
|
+
export function beatRun(id) {
|
|
197
|
+
if (!fold(readEvents()).runs.has(id))
|
|
198
|
+
throw new UserError(`no declared run "${id}"`);
|
|
199
|
+
appendEvent(() => ({ type: "run.beat", run: id }));
|
|
200
|
+
}
|
|
175
201
|
export function endRun(id, status, note) {
|
|
176
202
|
if (!fold(readEvents()).runs.has(id))
|
|
177
203
|
throw new UserError(`no declared run "${id}"`);
|
|
178
204
|
appendEvent(() => ({ type: "run.end", run: id, status, note }));
|
|
179
205
|
}
|
|
180
|
-
|
|
181
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Say which project and task a session works on. A claim without a project keeps the session's
|
|
208
|
+
* earlier claim and only changes what is given (for `--done`). A claim of a new task clears `done`.
|
|
209
|
+
*/
|
|
210
|
+
export function claimSession(input) {
|
|
211
|
+
if (input.session.length < 6)
|
|
182
212
|
throw new UserError("give at least 6 characters of the session id");
|
|
213
|
+
const before = fold(readEvents()).claims.find((c) => c.session === input.session);
|
|
214
|
+
const project = input.project?.trim() || before?.project;
|
|
215
|
+
if (!project)
|
|
216
|
+
throw new UserError("this session has not claimed a project yet: give --project");
|
|
217
|
+
const sameWork = !input.project || (input.project === before?.project && (input.task ?? before?.task) === before?.task);
|
|
218
|
+
const claim = {
|
|
219
|
+
session: input.session,
|
|
220
|
+
project,
|
|
221
|
+
task: input.task ?? (sameWork ? before?.task : undefined),
|
|
222
|
+
role: input.role ?? before?.role,
|
|
223
|
+
title: input.title ?? before?.title,
|
|
224
|
+
kind: input.kind ?? before?.kind,
|
|
225
|
+
// any new claim means work goes on; only --done marks it finished
|
|
226
|
+
done: input.done?.trim() || undefined,
|
|
227
|
+
};
|
|
228
|
+
for (const k of Object.keys(claim))
|
|
229
|
+
if (claim[k] === undefined)
|
|
230
|
+
delete claim[k];
|
|
183
231
|
appendEvent(() => ({ type: "run.claim", claim }));
|
|
232
|
+
return claim;
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------- pull requests
|
|
235
|
+
export function linkPr(link) {
|
|
236
|
+
const claims = fold(readEvents()).claims;
|
|
237
|
+
const claim = link.session ? claims.find((c) => link.session.startsWith(c.session)) : undefined;
|
|
238
|
+
const project = link.project?.trim() || claim?.project;
|
|
239
|
+
appendEvent(() => ({
|
|
240
|
+
type: "pr.link",
|
|
241
|
+
link: { pr: link.pr, session: link.session, project, task: link.task?.trim() || claim?.task },
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
export function unlinkPr(key) {
|
|
245
|
+
if (!fold(readEvents()).prLinks.has(key))
|
|
246
|
+
throw new UserError(`no linked pull request ${key}`);
|
|
247
|
+
appendEvent(() => ({ type: "pr.unlink", pr: key }));
|
|
184
248
|
}
|
|
185
|
-
/**
|
|
249
|
+
/** Something another tool saw on a PR (a merge bot, a ticket system), recorded like GitHub's own. */
|
|
250
|
+
export function prEvent(pr, change, opts = {}) {
|
|
251
|
+
if (!PR_CHANGES.includes(change))
|
|
252
|
+
throw new UserError(`change must be one of ${PR_CHANGES.join(", ")}`);
|
|
253
|
+
appendEvent(() => ({ type: "pr.change", pr, change: change, by: opts.by, text: opts.text?.trim() || undefined, title: opts.title, source: "external" }));
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Human answers and comments newer than `afterSeq`, on cards that are not closed, and news on pull
|
|
257
|
+
* requests linked to the project.
|
|
258
|
+
*/
|
|
186
259
|
export function inboxFrom(events, afterSeq, filter) {
|
|
187
260
|
const state = fold(events);
|
|
188
261
|
const out = [];
|
|
189
262
|
for (const ev of events) {
|
|
190
263
|
if (ev.seq <= afterSeq)
|
|
191
264
|
continue;
|
|
265
|
+
if (ev.type === "pr.change") {
|
|
266
|
+
const link = state.prLinks.get(ev.pr.key);
|
|
267
|
+
if (!link || filter.card || (filter.project && link.project !== filter.project))
|
|
268
|
+
continue;
|
|
269
|
+
out.push({ seq: ev.seq, at: ev.ts, kind: "pr", by: ev.by ?? ev.source, pr: { ref: ev.pr, change: ev.change, title: ev.title, text: ev.text, project: link.project } });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
192
272
|
if (ev.type !== "card.answer" && ev.type !== "card.comment")
|
|
193
273
|
continue;
|
|
194
274
|
if (ev.by !== HUMAN)
|
|
@@ -219,6 +299,70 @@ export function inboxFrom(events, afterSeq, filter) {
|
|
|
219
299
|
}
|
|
220
300
|
return out;
|
|
221
301
|
}
|
|
302
|
+
// ---------------------------------------------------------------- listeners
|
|
303
|
+
function listenersDir() {
|
|
304
|
+
return path.join(homeDir(), "listeners");
|
|
305
|
+
}
|
|
306
|
+
/** Mark this process as waiting, so cards can say whether an answer reaches an agent at once. */
|
|
307
|
+
export function startListening(l) {
|
|
308
|
+
const file = path.join(listenersDir(), `${process.pid}.json`);
|
|
309
|
+
fs.mkdirSync(listenersDir(), { recursive: true });
|
|
310
|
+
const me = { ...l, pid: process.pid, since: new Date().toISOString() };
|
|
311
|
+
fs.writeFileSync(file, JSON.stringify(me) + "\n");
|
|
312
|
+
let done = false;
|
|
313
|
+
const stop = () => {
|
|
314
|
+
if (done)
|
|
315
|
+
return;
|
|
316
|
+
done = true;
|
|
317
|
+
fs.rmSync(file, { force: true });
|
|
318
|
+
};
|
|
319
|
+
process.once("exit", stop);
|
|
320
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
321
|
+
process.once(sig, () => {
|
|
322
|
+
stop();
|
|
323
|
+
process.exit(128 + (sig === "SIGINT" ? 2 : sig === "SIGTERM" ? 15 : 1));
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return stop;
|
|
327
|
+
}
|
|
328
|
+
function alive(pid) {
|
|
329
|
+
try {
|
|
330
|
+
process.kill(pid, 0);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
return err.code === "EPERM";
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Waits running now. Files left by a process that died are removed. */
|
|
338
|
+
export function listeners() {
|
|
339
|
+
let names;
|
|
340
|
+
try {
|
|
341
|
+
names = fs.readdirSync(listenersDir()).filter((f) => f.endsWith(".json"));
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
const out = [];
|
|
347
|
+
for (const n of names) {
|
|
348
|
+
const file = path.join(listenersDir(), n);
|
|
349
|
+
try {
|
|
350
|
+
const l = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
351
|
+
if (alive(l.pid))
|
|
352
|
+
out.push(l);
|
|
353
|
+
else
|
|
354
|
+
fs.rmSync(file, { force: true });
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
// half written; read it next time
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
/** The waits an answer to this card would wake right now. */
|
|
363
|
+
export function listenersFor(card, ls) {
|
|
364
|
+
return ls.filter((l) => (l.card ? l.card === card.id : !l.project || l.project === card.project));
|
|
365
|
+
}
|
|
222
366
|
export function consumerName(opts) {
|
|
223
367
|
return opts.as || opts.project || (opts.card ? `card-${opts.card}` : "default");
|
|
224
368
|
}
|
package/dist/core/runs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { scanAll } from "../adapters/index.js";
|
|
4
5
|
import { ACTIVITY_MINUTES, activeMinutes } from "../adapters/jsonl.js";
|
|
@@ -64,24 +65,70 @@ function projectForPrompt(prompt, ms) {
|
|
|
64
65
|
}
|
|
65
66
|
return hits.size === 1 ? [...hits][0] : undefined;
|
|
66
67
|
}
|
|
68
|
+
/** The project most of these folders belong to (ties go to the most recent), with its count. */
|
|
69
|
+
function projectForEdits(dirs, ms) {
|
|
70
|
+
const counts = new Map();
|
|
71
|
+
for (const d of dirs ?? []) {
|
|
72
|
+
const p = projectForCwd(d, ms);
|
|
73
|
+
if (p)
|
|
74
|
+
counts.set(p, (counts.get(p) ?? 0) + 1);
|
|
75
|
+
}
|
|
76
|
+
return counts;
|
|
77
|
+
}
|
|
78
|
+
function topOf(counts, dirs, ms) {
|
|
79
|
+
let best;
|
|
80
|
+
let n = 0;
|
|
81
|
+
// walk newest first so a tie goes to where the run worked last
|
|
82
|
+
for (const d of [...(dirs ?? [])].reverse()) {
|
|
83
|
+
const p = projectForCwd(d, ms);
|
|
84
|
+
if (p && (counts.get(p) ?? 0) > n) {
|
|
85
|
+
best = p;
|
|
86
|
+
n = counts.get(p);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return best;
|
|
90
|
+
}
|
|
91
|
+
function claimFor(id, state) {
|
|
92
|
+
return state.claims.find((c) => id.startsWith(c.session));
|
|
93
|
+
}
|
|
67
94
|
/**
|
|
68
|
-
* Give each observed run a project, in this order: an explicit claim
|
|
69
|
-
* it
|
|
95
|
+
* Give each observed run a project, in this order: an explicit claim; the checkout it runs in,
|
|
96
|
+
* unless it only edits files of another project; its parent's project; the project whose files it
|
|
97
|
+
* edits most; a project path named in its first prompt.
|
|
70
98
|
*/
|
|
71
99
|
export function attribute(runs, state) {
|
|
72
100
|
const ms = matchers([...state.projects.values()]);
|
|
73
101
|
const byId = new Map(runs.map((r) => [r.id, r]));
|
|
74
102
|
for (const r of runs) {
|
|
75
|
-
const claim =
|
|
103
|
+
const claim = claimFor(r.id, state);
|
|
76
104
|
if (claim) {
|
|
77
105
|
r.project = claim.project;
|
|
106
|
+
r.projectSource = "claim";
|
|
78
107
|
r.task = claim.task ?? r.task;
|
|
79
108
|
r.role = claim.role ?? r.role;
|
|
80
109
|
if (claim.title)
|
|
81
110
|
r.title = claim.title;
|
|
111
|
+
if (claim.kind === "scheduled")
|
|
112
|
+
r.scheduled = true;
|
|
113
|
+
// done until someone writes to the session again
|
|
114
|
+
if (claim.done && claim.at && (!r.lastPromptAt || r.lastPromptAt < claim.at)) {
|
|
115
|
+
r.done = claim.done;
|
|
116
|
+
if (r.state === "idle")
|
|
117
|
+
r.state = "done";
|
|
118
|
+
r.asks = undefined;
|
|
119
|
+
}
|
|
82
120
|
continue;
|
|
83
121
|
}
|
|
84
|
-
|
|
122
|
+
const here = projectForCwd(r.cwd, ms);
|
|
123
|
+
const edits = projectForEdits(r.touched, ms);
|
|
124
|
+
if (here && !r.parent && edits.size && !edits.has(here)) {
|
|
125
|
+
r.project = topOf(edits, r.touched, ms);
|
|
126
|
+
r.projectSource = "edits";
|
|
127
|
+
}
|
|
128
|
+
else if (here) {
|
|
129
|
+
r.project = here;
|
|
130
|
+
r.projectSource = "cwd";
|
|
131
|
+
}
|
|
85
132
|
}
|
|
86
133
|
// parents may themselves get a project only from a claim, so resolve children afterwards
|
|
87
134
|
for (let pass = 0; pass < 3; pass++) {
|
|
@@ -89,18 +136,73 @@ export function attribute(runs, state) {
|
|
|
89
136
|
if (r.project || !r.parent)
|
|
90
137
|
continue;
|
|
91
138
|
const p = byId.get(r.parent);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
r.
|
|
139
|
+
const project = p?.project ?? claimFor(r.parent, state)?.project;
|
|
140
|
+
if (project) {
|
|
141
|
+
r.project = project;
|
|
142
|
+
r.projectSource = "parent";
|
|
143
|
+
}
|
|
96
144
|
}
|
|
97
145
|
}
|
|
98
146
|
for (const r of runs) {
|
|
99
|
-
if (
|
|
100
|
-
|
|
147
|
+
if (r.project)
|
|
148
|
+
continue;
|
|
149
|
+
const edits = topOf(projectForEdits(r.touched, ms), r.touched, ms);
|
|
150
|
+
const prompt = edits ? undefined : projectForPrompt(r.prompt, ms);
|
|
151
|
+
r.project = edits ?? prompt;
|
|
152
|
+
if (r.project)
|
|
153
|
+
r.projectSource = edits ? "edits" : "prompt";
|
|
101
154
|
}
|
|
102
155
|
return runs;
|
|
103
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Fold repeated runs of a scheduled task into the newest one, with a count. Runs claimed as
|
|
159
|
+
* scheduled group by project and task; unclaimed top-level sessions group by an identical first
|
|
160
|
+
* prompt. A run still working is never folded away.
|
|
161
|
+
*/
|
|
162
|
+
export function foldRepeats(runs) {
|
|
163
|
+
const groups = new Map();
|
|
164
|
+
for (const r of runs) {
|
|
165
|
+
if (r.kind !== "session" || r.parent)
|
|
166
|
+
continue;
|
|
167
|
+
const key = r.scheduled ? `s:${r.project}:${r.task ?? r.title}` : r.projectSource === "claim" || !r.prompt ? undefined : `p:${r.prompt}`;
|
|
168
|
+
if (!key)
|
|
169
|
+
continue;
|
|
170
|
+
if (!groups.has(key))
|
|
171
|
+
groups.set(key, []);
|
|
172
|
+
groups.get(key).push(r);
|
|
173
|
+
}
|
|
174
|
+
const drop = new Set();
|
|
175
|
+
for (const g of groups.values()) {
|
|
176
|
+
if (g.length < 2)
|
|
177
|
+
continue;
|
|
178
|
+
g.sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
|
|
179
|
+
const [newest, ...older] = g;
|
|
180
|
+
let n = 0;
|
|
181
|
+
for (const r of older) {
|
|
182
|
+
if (r.state === "working" || r.state === "waiting" || r.asks)
|
|
183
|
+
continue;
|
|
184
|
+
drop.add(r);
|
|
185
|
+
n++;
|
|
186
|
+
}
|
|
187
|
+
if (n) {
|
|
188
|
+
newest.repeats = n;
|
|
189
|
+
newest.scheduled = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// children of a folded run go with it
|
|
193
|
+
const gone = new Set([...drop].map((r) => r.id));
|
|
194
|
+
return runs.filter((r) => !drop.has(r) && !(r.parent && gone.has(r.parent)));
|
|
195
|
+
}
|
|
196
|
+
/** Linked pull requests, put on the run that owns them. */
|
|
197
|
+
function attachPrs(runs, state) {
|
|
198
|
+
for (const link of state.prLinks.values()) {
|
|
199
|
+
if (!link.session)
|
|
200
|
+
continue;
|
|
201
|
+
const r = runs.find((x) => x.id.startsWith(link.session));
|
|
202
|
+
if (r)
|
|
203
|
+
(r.prs ??= []).push(link.pr.key);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
104
206
|
/** A declared run is busy from its start until it ends (or now, while it runs). */
|
|
105
207
|
function declaredActivity(start, end, now) {
|
|
106
208
|
const from = Date.parse(start);
|
|
@@ -112,21 +214,38 @@ function declaredActivity(start, end, now) {
|
|
|
112
214
|
times.push(to);
|
|
113
215
|
return activeMinutes(times, now);
|
|
114
216
|
}
|
|
217
|
+
/** When a declared run last showed life: its last beat or note, or its log file changing. */
|
|
218
|
+
function lastSign(d) {
|
|
219
|
+
if (!d.log)
|
|
220
|
+
return d.lastActivity;
|
|
221
|
+
try {
|
|
222
|
+
const m = new Date(fs.statSync(d.log).mtimeMs).toISOString();
|
|
223
|
+
return m > d.lastActivity ? m : d.lastActivity;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return d.lastActivity;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
115
229
|
export function declaredRuns(state, now = Date.now()) {
|
|
116
|
-
return [...state.runs.values()].map((d) =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
230
|
+
return [...state.runs.values()].map((d) => {
|
|
231
|
+
const last = d.status === "running" ? lastSign(d) : d.lastActivity;
|
|
232
|
+
const quiet = d.status === "running" && d.heartbeat !== undefined && now - Date.parse(last) > d.heartbeat * 1000;
|
|
233
|
+
return {
|
|
234
|
+
id: d.id,
|
|
235
|
+
runtime: "declared",
|
|
236
|
+
kind: "declared",
|
|
237
|
+
title: d.title,
|
|
238
|
+
project: d.project,
|
|
239
|
+
task: d.task,
|
|
240
|
+
parent: d.parent,
|
|
241
|
+
startedAt: d.startedAt,
|
|
242
|
+
lastActivity: last,
|
|
243
|
+
state: quiet ? "stalled" : d.status === "running" ? "working" : d.status,
|
|
244
|
+
action: quiet ? `no sign of life for ${Math.round((now - Date.parse(last)) / 60_000)}m${d.note ? ` · ${d.note}` : ""}` : d.note,
|
|
245
|
+
projectSource: "claim",
|
|
246
|
+
activity: declaredActivity(d.startedAt, d.status === "running" ? undefined : (d.endedAt ?? d.lastActivity), now),
|
|
247
|
+
};
|
|
248
|
+
});
|
|
130
249
|
}
|
|
131
250
|
const ENDED = new Set(["done", "idle", "failed"]);
|
|
132
251
|
const RECENT_END_MS = 30 * 60_000;
|
|
@@ -137,6 +256,7 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
137
256
|
const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
|
|
138
257
|
let runs = attribute(scanAll({ sinceMs, now }, q.runtimes), state).filter(recent);
|
|
139
258
|
runs.push(...declaredRuns(state, now).filter(recent));
|
|
259
|
+
attachPrs(runs, state);
|
|
140
260
|
if (q.project)
|
|
141
261
|
runs = runs.filter((r) => r.project === q.project);
|
|
142
262
|
if (!q.includeEnded) {
|
|
@@ -148,6 +268,8 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
148
268
|
return r.lastActivity ? now - Date.parse(r.lastActivity) < OLD_STALL_MS : true;
|
|
149
269
|
if (!ENDED.has(r.state))
|
|
150
270
|
return true;
|
|
271
|
+
if (r.asks)
|
|
272
|
+
return true; // still waiting for the human to read it
|
|
151
273
|
if (live.has(r.id))
|
|
152
274
|
return true;
|
|
153
275
|
if (r.role === "lead")
|
|
@@ -155,6 +277,8 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
155
277
|
return r.lastActivity ? now - Date.parse(r.lastActivity) < RECENT_END_MS : false;
|
|
156
278
|
});
|
|
157
279
|
}
|
|
280
|
+
if (!q.keepRepeats)
|
|
281
|
+
runs = foldRepeats(runs);
|
|
158
282
|
return runs.sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
|
|
159
283
|
}
|
|
160
284
|
//# sourceMappingURL=runs.js.map
|
package/dist/core/snapshot.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { prViews, keyOf, parsePrRef, readCache, githubConfig } from "./github.js";
|
|
2
|
+
import { linkPr, listeners } from "./ops.js";
|
|
1
3
|
import { collectRuns } from "./runs.js";
|
|
2
4
|
import { fold } from "./state.js";
|
|
3
5
|
import { readEvents } from "./store.js";
|
|
@@ -5,13 +7,58 @@ import { SCHEMA_VERSION } from "./types.js";
|
|
|
5
7
|
/**
|
|
6
8
|
* `closed`: also carry the most recently closed cards, newest first, so a panel can show them
|
|
7
9
|
* again. Off by default; open and answered cards are always there.
|
|
10
|
+
*
|
|
11
|
+
* GitHub is read from the last reading only; `syncGithub` refreshes it.
|
|
8
12
|
*/
|
|
9
13
|
export function snapshot(opts = {}) {
|
|
10
|
-
|
|
14
|
+
let state = fold(readEvents());
|
|
15
|
+
const runs = collectRuns(state, { sinceHours: opts.sinceHours ?? 6, project: opts.project, keepRepeats: opts.keepRepeats });
|
|
16
|
+
if (linkCreatedPrs(runs, state))
|
|
17
|
+
state = fold(readEvents());
|
|
11
18
|
const all = [...state.cards.values()].filter((c) => !opts.project || c.project === opts.project);
|
|
12
19
|
const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)];
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
quietAnsweredAsks(runs, all);
|
|
21
|
+
const cache = readCache();
|
|
22
|
+
const enabled = githubConfig().enabled && (cache.hosts === undefined ? false : cache.hosts.length > 0);
|
|
23
|
+
let prs = enabled ? prViews(cache, state, runs) : undefined;
|
|
24
|
+
if (prs && opts.project)
|
|
25
|
+
prs = prs.filter((p) => p.project === opts.project);
|
|
26
|
+
return {
|
|
27
|
+
v: SCHEMA_VERSION,
|
|
28
|
+
generatedAt: new Date().toISOString(),
|
|
29
|
+
projects: [...state.projects.values()],
|
|
30
|
+
cards,
|
|
31
|
+
runs,
|
|
32
|
+
prs,
|
|
33
|
+
github: { enabled, fetchedAt: cache.fetchedAt, error: cache.error },
|
|
34
|
+
listeners: listeners(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** A pull request a session's log shows it opened belongs to that session, unless linked already. */
|
|
38
|
+
function linkCreatedPrs(runs, state) {
|
|
39
|
+
let added = false;
|
|
40
|
+
for (const r of runs) {
|
|
41
|
+
for (const url of r.createdPrs ?? []) {
|
|
42
|
+
const key = keyOf(url);
|
|
43
|
+
const pr = parsePrRef(url);
|
|
44
|
+
if (!key || !pr || state.prLinks.has(key))
|
|
45
|
+
continue;
|
|
46
|
+
linkPr({ pr, session: r.id, project: r.project, task: r.task });
|
|
47
|
+
state.prLinks.set(key, { pr, session: r.id, project: r.project, at: new Date().toISOString() });
|
|
48
|
+
added = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return added;
|
|
52
|
+
}
|
|
53
|
+
/** A question asked in chat is no longer pending once the session put it on a card. */
|
|
54
|
+
function quietAnsweredAsks(runs, cards) {
|
|
55
|
+
for (const r of runs) {
|
|
56
|
+
if (!r.asks)
|
|
57
|
+
continue;
|
|
58
|
+
const turn = r.lastPromptAt ?? "";
|
|
59
|
+
if (cards.some((c) => c.status !== "closed" && c.session && r.id.startsWith(c.session) && c.openedAt >= turn))
|
|
60
|
+
r.asks = undefined;
|
|
61
|
+
}
|
|
15
62
|
}
|
|
16
63
|
export function recentlyClosed(cards, keep) {
|
|
17
64
|
if (keep <= 0)
|
package/dist/core/state.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Rebuild current state from the event log. */
|
|
2
2
|
export function fold(events) {
|
|
3
|
-
const s = { projects: new Map(), cards: new Map(), runs: new Map(), claims: [], lastSeq: 0 };
|
|
3
|
+
const s = { projects: new Map(), cards: new Map(), runs: new Map(), claims: [], prLinks: new Map(), lastSeq: 0 };
|
|
4
4
|
for (const ev of events) {
|
|
5
5
|
s.lastSeq = Math.max(s.lastSeq, ev.seq);
|
|
6
6
|
switch (ev.type) {
|
|
@@ -61,6 +61,12 @@ export function fold(events) {
|
|
|
61
61
|
r.lastActivity = ev.ts;
|
|
62
62
|
break;
|
|
63
63
|
}
|
|
64
|
+
case "run.beat": {
|
|
65
|
+
const r = s.runs.get(ev.run);
|
|
66
|
+
if (r)
|
|
67
|
+
r.lastActivity = ev.ts;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
64
70
|
case "run.end": {
|
|
65
71
|
const r = s.runs.get(ev.run);
|
|
66
72
|
if (!r)
|
|
@@ -75,7 +81,13 @@ export function fold(events) {
|
|
|
75
81
|
case "run.claim":
|
|
76
82
|
// a later claim of the same session replaces the earlier one
|
|
77
83
|
s.claims = s.claims.filter((c) => c.session !== ev.claim.session);
|
|
78
|
-
s.claims.push(ev.claim);
|
|
84
|
+
s.claims.push({ ...ev.claim, at: ev.ts });
|
|
85
|
+
break;
|
|
86
|
+
case "pr.link":
|
|
87
|
+
s.prLinks.set(ev.link.pr.key, { ...ev.link, at: ev.ts });
|
|
88
|
+
break;
|
|
89
|
+
case "pr.unlink":
|
|
90
|
+
s.prLinks.delete(ev.pr);
|
|
79
91
|
break;
|
|
80
92
|
}
|
|
81
93
|
}
|
package/dist/core/store.js
CHANGED
|
@@ -90,18 +90,17 @@ export function parseEventLines(text) {
|
|
|
90
90
|
}
|
|
91
91
|
/** Append one event. `build` sees the current events so it can pick ids under the lock. */
|
|
92
92
|
export function appendEvent(build) {
|
|
93
|
-
return withLock(() =>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
fs.appendFileSync(eventsPath(), JSON.stringify(
|
|
103
|
-
|
|
104
|
-
});
|
|
93
|
+
return withLock(() => appendLocked((events) => [build(events)])[0]);
|
|
94
|
+
}
|
|
95
|
+
/** Append events while already holding the lock (inside `withLock`). */
|
|
96
|
+
export function appendLocked(build) {
|
|
97
|
+
const events = readEvents();
|
|
98
|
+
let seq = events[events.length - 1]?.seq ?? 0;
|
|
99
|
+
const ts = new Date().toISOString();
|
|
100
|
+
const out = build(events).map((body) => ({ v: SCHEMA_VERSION, seq: ++seq, ts, ...body }));
|
|
101
|
+
if (out.length)
|
|
102
|
+
fs.appendFileSync(eventsPath(), out.map((e) => JSON.stringify(e) + "\n").join(""));
|
|
103
|
+
return out;
|
|
105
104
|
}
|
|
106
105
|
const CURSOR_NAME = /^[A-Za-z0-9._-]+$/;
|
|
107
106
|
function cursorPath(name) {
|
package/dist/core/types.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const SCHEMA_VERSION = 1;
|
|
2
2
|
export const CARD_KINDS = ["decide", "try", "block", "todo"];
|
|
3
|
+
export const PR_CHANGES = ["review", "approved", "changes_requested", "commented", "ci_failed", "ci_passed", "queued", "queue_removed", "conflict", "merged", "closed", "note"];
|
|
3
4
|
/** Who counts as a human when deciding what wakes an agent. */
|
|
4
5
|
export const HUMAN = "human";
|
|
5
6
|
//# sourceMappingURL=types.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parawatch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A control tower for people running many AI coding agents in parallel: see what is running, what needs you, and wake the right agent when you answer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|