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/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 options = cleanList(input.options);
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
- export function claimSession(claim) {
181
- if (claim.session.length < 6)
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
- /** Human answers and comments newer than `afterSeq`, on cards that are not closed. */
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, the checkout
69
- * it runs in, its parent's project, a project path named in its first prompt.
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 = state.claims.find((c) => r.id.startsWith(c.session));
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
- r.project = projectForCwd(r.cwd, ms);
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
- if (p?.project)
93
- r.project = p.project;
94
- else
95
- r.project = state.claims.find((c) => r.parent.startsWith(c.session))?.project;
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 (!r.project)
100
- r.project = projectForPrompt(r.prompt, ms);
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
- id: d.id,
118
- runtime: "declared",
119
- kind: "declared",
120
- title: d.title,
121
- project: d.project,
122
- task: d.task,
123
- parent: d.parent,
124
- startedAt: d.startedAt,
125
- lastActivity: d.lastActivity,
126
- state: d.status === "running" ? "working" : d.status,
127
- action: d.note,
128
- activity: declaredActivity(d.startedAt, d.status === "running" ? undefined : (d.endedAt ?? d.lastActivity), now),
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
@@ -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
- const state = fold(readEvents());
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
- const runs = collectRuns(state, { sinceHours: opts.sinceHours ?? 6, project: opts.project });
14
- return { v: SCHEMA_VERSION, generatedAt: new Date().toISOString(), projects: [...state.projects.values()], cards, runs };
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)
@@ -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
  }
@@ -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
- const events = readEvents();
95
- const last = events[events.length - 1];
96
- const ev = {
97
- v: SCHEMA_VERSION,
98
- seq: (last?.seq ?? 0) + 1,
99
- ts: new Date().toISOString(),
100
- ...build(events),
101
- };
102
- fs.appendFileSync(eventsPath(), JSON.stringify(ev) + "\n");
103
- return ev;
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) {
@@ -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.1.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",