parawatch 0.3.1 → 0.3.3
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 +8 -5
- package/dist/bin/cli.js +22 -11
- package/dist/core/desktop.js +68 -17
- package/dist/core/format.js +6 -2
- package/dist/core/github.js +19 -2
- package/dist/core/integrate.js +14 -8
- package/dist/core/ops.js +100 -16
- package/dist/core/paths.js +56 -0
- package/dist/core/runs.js +145 -25
- package/dist/core/snapshot.js +3 -2
- package/dist/core/state.js +7 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +6 -3
package/README.md
CHANGED
|
@@ -154,7 +154,7 @@ parawatch ask -p api "Merge PR 88?" --action "gh pr merge 88 --squash" --preview
|
|
|
154
154
|
parawatch try -p game "Try build 139" --open /path/Game.app --look "does the glass read?"
|
|
155
155
|
parawatch block -p app "Install the iPad simulator runtime"
|
|
156
156
|
parawatch todo -p game "Check the GitHub bill" --since 2026-10-01
|
|
157
|
-
parawatch wait
|
|
157
|
+
parawatch wait # run in the background; exits when the human answers
|
|
158
158
|
parawatch comment c4 "baking the top box now" # a note on the card; does not wake anyone
|
|
159
159
|
parawatch close c4 -n "used the top box"
|
|
160
160
|
parawatch run start -p game "overnight bench" --heartbeat 600 --log bench.log # a job with no agent log
|
|
@@ -167,7 +167,7 @@ An approval (`ask --action`) shows the exact command and the text it sends, with
|
|
|
167
167
|
|
|
168
168
|
`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.
|
|
169
169
|
|
|
170
|
-
`wait` prints the answers, and news on
|
|
170
|
+
`wait` prints the answers, and news on linked pull requests, and exits with code 0. Run from an agent session, it reads that session's own cards and pull requests, and the ones no session owns in the project it claimed, with a cursor of its own: four sessions in one project each wake only for their own news. `wait -p <project>` reads the whole project, for a lead. With `--timeout <seconds>` it exits with code 2 when nothing came. An answer given before `wait` starts is not lost: each consumer has its own cursor, and `wait` returns everything after it. While a `wait` runs, cards say an agent is waiting; when none does, an answered card says the answer is kept until the agent next checks.
|
|
171
171
|
|
|
172
172
|
### Agent skill
|
|
173
173
|
|
|
@@ -177,9 +177,9 @@ An approval (`ask --action`) shows the exact command and the text it sends, with
|
|
|
177
177
|
|
|
178
178
|
### Waking a Claude Code session
|
|
179
179
|
|
|
180
|
-
Claude Code re-invokes a session when one of its background commands exits. So an agent that runs `parawatch wait
|
|
180
|
+
Claude Code re-invokes a session when one of its background commands exits. So an agent that runs `parawatch wait` as a background command is woken by your answer, with the answer as the command's output.
|
|
181
181
|
|
|
182
|
-
As a backstop, the hook adds
|
|
182
|
+
As a backstop, the hook adds the session's unhandled answers and pull request news to its next turn:
|
|
183
183
|
|
|
184
184
|
```json
|
|
185
185
|
{
|
|
@@ -290,10 +290,13 @@ parawatch sync # this machine, the others, how the sync tool is doing
|
|
|
290
290
|
- **Ids carry the machine once a log is shared:** `pc-c3`, `mac-r2`. Commands take the short form (`parawatch close c3`) while only one machine has that number. Cards from before the move keep their ids.
|
|
291
291
|
- **When two machines answer one card before either has seen the other's answer, the earlier answer stands.** The other one stays on the card, marked as not used, and does not wake the agent. An answer given after seeing the first one counts as usual.
|
|
292
292
|
- **Paths follow the machine.** Attachments, things to open and project folders written on another machine are read as the same place here, under its home folder or the shared folder. Name other folders that sit in different places on each machine (a cloud drive, say): `parawatch sync root gdrive "G:\My Drive"` on one machine and `parawatch sync root gdrive ~/Library/CloudStorage/…/My\ Drive` on the other. Pasted pictures are saved in the shared folder, so every machine can open them.
|
|
293
|
+
- **A path that is absolute on either system is kept as written**, so an agent on the PC can ask you to open `/Users/Shared/Try/Game.app` on the Mac. Git Bash on Windows rewrites arguments that start with `/` before parawatch sees them; run such commands there with `MSYS_NO_PATHCONV=1`.
|
|
293
294
|
- **Waits are seen everywhere.** A card on your laptop says whether an agent on the PC is waiting for it.
|
|
295
|
+
- **Runs are seen everywhere.** Each machine writes a summary of the sessions its agent logs show (state, title, project, last activity, what each does) to `runs/<device>.json` about every half minute while `status`, a `wait` or the app runs. The other machines show those runs marked with the machine. The logs stay where they are. A machine whose summary is older than 15 minutes drops out of the list.
|
|
294
296
|
- **Every machine reads GitHub for itself.** The same news is delivered once.
|
|
297
|
+
- **Cards reach you as far as the sync tool does.** With Syncthing, leave global discovery and relays on (its defaults) to stay in step from another network; with them off, machines only meet on the same network or a VPN. `parawatch sync` shows each connection's address, a relay included.
|
|
295
298
|
|
|
296
|
-
The shared folder holds `events/`, `devices/` (each machine's paths, version and last sign of life), `listeners/` and `attachments/`. Cursors, the lock, the GitHub reading and settings stay on each machine.
|
|
299
|
+
The shared folder holds `events/`, `devices/` (each machine's paths, version and last sign of life), `runs/`, `listeners/` and `attachments/`. Cursors, the lock, the GitHub reading and settings stay on each machine.
|
|
297
300
|
|
|
298
301
|
**Sync adapters** are how parawatch asks the sync tool how it is doing, for `parawatch sync`, `doctor` and the app's settings. Pick one with `--adapter` (or `"sync": { "adapter": … }` in `config.json`):
|
|
299
302
|
|
package/dist/bin/cli.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, commentCard,
|
|
5
|
+
import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, commentCard, inboxScope, editCard, endRun, linkPr, listeners, listenersFor, noteRun, openCard, prEvent, findCard, readInbox, readState, removeProject, startListening, startRun, unlinkPr, waitInbox, } from "../core/ops.js";
|
|
6
6
|
import { githubConfig, readCache, resolvePrRef, syncGithub, prViews } from "../core/github.js";
|
|
7
7
|
import { digest, digestText, localDate } from "../core/digest.js";
|
|
8
8
|
import { everywhere, readEvents } from "../core/store.js";
|
|
9
9
|
import { followEvents } from "../core/follow.js";
|
|
10
10
|
import { adapterNames, setRoot, setupSync, syncAdapter, syncStatus, touchDevice } from "../core/sync.js";
|
|
11
11
|
import { syncText } from "../core/format.js";
|
|
12
|
-
import { collectRuns } from "../core/runs.js";
|
|
12
|
+
import { collectRuns, shareRunsDue } from "../core/runs.js";
|
|
13
13
|
import { askRows, cardDetail, cardLine, cardRows, inboxLine, prRows, runsTree, view } from "../core/format.js";
|
|
14
14
|
import { hookContext, notify, removeInstructions, writeInstructions } from "../core/integrate.js";
|
|
15
15
|
import { installSkill, skillSource, skillTargets, uninstallSkill } from "../core/skill.js";
|
|
@@ -204,21 +204,30 @@ program
|
|
|
204
204
|
// ------------------------------------------------------------------ inbox
|
|
205
205
|
function inboxOptions(c) {
|
|
206
206
|
return c
|
|
207
|
-
.option("-p, --project <name>", "
|
|
207
|
+
.option("-p, --project <name>", "everything in this project, whoever owns it (for a lead)")
|
|
208
208
|
.option("--card <id>", "only answers to this card")
|
|
209
|
-
.option("--as <consumer>", "cursor name (defaults to the project)")
|
|
209
|
+
.option("--as <consumer>", "cursor name (defaults to the session, or the project with -p)")
|
|
210
210
|
.option("--json", "JSON output");
|
|
211
211
|
}
|
|
212
|
-
inboxOptions(program
|
|
212
|
+
inboxOptions(program
|
|
213
|
+
.command("wait")
|
|
214
|
+
.description("block until the human answers or a linked pull request has news, print it and exit. In an agent session it reads that session's own cards and pull requests"))
|
|
213
215
|
.option("--timeout <seconds>", "give up after this long (exit code 2); 0 waits forever", int, 0)
|
|
214
216
|
.action(async (o) => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
217
|
+
const session = currentSession();
|
|
218
|
+
const { consumer, filter } = inboxScope(o, session);
|
|
219
|
+
startListening({ consumer, project: filter.project, card: filter.card, session, ...(filter.session ? { scope: "session" } : {}) });
|
|
220
|
+
// while waiting, keep GitHub fresh so news on linked pull requests wakes this wait, and tell
|
|
221
|
+
// the other machines what runs here
|
|
222
|
+
const tick = () => {
|
|
223
|
+
void refreshGithub();
|
|
224
|
+
if (shareRunsDue())
|
|
225
|
+
collectRuns(readState(), { sinceHours: 6 });
|
|
226
|
+
};
|
|
218
227
|
tick();
|
|
219
228
|
const timer = setInterval(tick, 60_000);
|
|
220
229
|
timer.unref();
|
|
221
|
-
const items = await waitInbox(
|
|
230
|
+
const items = await waitInbox(consumer, filter, o.timeout * 1000);
|
|
222
231
|
clearInterval(timer);
|
|
223
232
|
if (!items.length) {
|
|
224
233
|
process.exitCode = 2;
|
|
@@ -232,7 +241,8 @@ inboxOptions(program.command("wait").description("block until the human answers,
|
|
|
232
241
|
inboxOptions(program.command("inbox").description("print unhandled answers without waiting"))
|
|
233
242
|
.option("--peek", "do not mark them as seen")
|
|
234
243
|
.action((o) => {
|
|
235
|
-
const
|
|
244
|
+
const { consumer, filter } = inboxScope(o, currentSession());
|
|
245
|
+
const items = readInbox(consumer, filter, !o.peek);
|
|
236
246
|
if (o.json)
|
|
237
247
|
print({ v: SCHEMA_VERSION, items });
|
|
238
248
|
else if (items.length)
|
|
@@ -415,8 +425,9 @@ program
|
|
|
415
425
|
}
|
|
416
426
|
if (answered.length) {
|
|
417
427
|
const notes = {};
|
|
428
|
+
// the snapshot already marked the cards whose session has ended
|
|
418
429
|
for (const c of answered)
|
|
419
|
-
if (!listenersFor(c, ls).length)
|
|
430
|
+
if (!listenersFor(c, ls, () => false).length)
|
|
420
431
|
notes[c.id] = "no agent waiting";
|
|
421
432
|
out.push("", head("ANSWERED, WAITING FOR THE AGENT", answered.length));
|
|
422
433
|
out.push(...cardRows(answered, v, now, " ", notes));
|
package/dist/core/desktop.js
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parsePrRef } from "./github.js";
|
|
5
|
-
import {
|
|
5
|
+
import { UserError } from "./errors.js";
|
|
6
|
+
import { sessionTitle } from "./state.js";
|
|
6
7
|
/**
|
|
7
8
|
* Where the Claude desktop app keeps its Code sessions: one JSON file per session, named
|
|
8
9
|
* `local_<id>.json`, holding the CLI session id the app runs (`cliSessionId`).
|
|
@@ -27,36 +28,86 @@ function* sessionFiles(dir, depth = 0) {
|
|
|
27
28
|
yield p;
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
/** Session files read before, by path; a file is read again only when it changes. */
|
|
32
|
+
const readCache = new Map();
|
|
33
|
+
/** Every desktop app session in the folder, read once per change of its file. */
|
|
34
|
+
function desktopSessions(dir) {
|
|
35
|
+
const out = [];
|
|
35
36
|
for (const file of sessionFiles(dir)) {
|
|
36
|
-
let
|
|
37
|
+
let mtimeMs;
|
|
37
38
|
try {
|
|
38
|
-
|
|
39
|
+
mtimeMs = fs.statSync(file).mtimeMs;
|
|
39
40
|
}
|
|
40
41
|
catch {
|
|
41
42
|
continue;
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
let hit = readCache.get(file);
|
|
45
|
+
if (!hit || hit.mtimeMs !== mtimeMs) {
|
|
46
|
+
hit = { mtimeMs, session: readSessionFile(file) };
|
|
47
|
+
readCache.set(file, hit);
|
|
48
|
+
}
|
|
49
|
+
if (hit.session)
|
|
50
|
+
out.push(hit.session);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function readSessionFile(file) {
|
|
55
|
+
let d;
|
|
56
|
+
try {
|
|
57
|
+
d = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const ids = [d.cliSessionId, d.lastAssistantCliSessionId].filter((x) => typeof x === "string");
|
|
63
|
+
if (!ids.length)
|
|
64
|
+
return undefined;
|
|
65
|
+
return {
|
|
66
|
+
id: typeof d.sessionId === "string" ? d.sessionId : path.basename(file, ".json"),
|
|
67
|
+
cliSessionId: ids[0],
|
|
68
|
+
ids,
|
|
69
|
+
title: typeof d.title === "string" ? d.title : undefined,
|
|
70
|
+
cwd: typeof d.cwd === "string" ? d.cwd : undefined,
|
|
71
|
+
archived: d.isArchived === true,
|
|
72
|
+
lastActivityAt: typeof d.lastActivityAt === "number" ? d.lastActivityAt : 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** The desktop app session running a CLI session (full id or a prefix of 6+ characters). */
|
|
76
|
+
export function findDesktopSession(cliSession, dir = desktopSessionsDir()) {
|
|
77
|
+
if (cliSession.length < 6)
|
|
78
|
+
return undefined;
|
|
79
|
+
let best;
|
|
80
|
+
for (const { ids, ...s } of desktopSessions(dir)) {
|
|
44
81
|
if (!ids.some((x) => x.startsWith(cliSession)))
|
|
45
82
|
continue;
|
|
46
|
-
const s = {
|
|
47
|
-
id: typeof d.sessionId === "string" ? d.sessionId : path.basename(file, ".json"),
|
|
48
|
-
cliSessionId: ids[0],
|
|
49
|
-
title: typeof d.title === "string" ? d.title : undefined,
|
|
50
|
-
cwd: typeof d.cwd === "string" ? d.cwd : undefined,
|
|
51
|
-
archived: d.isArchived === true,
|
|
52
|
-
lastActivityAt: typeof d.lastActivityAt === "number" ? d.lastActivityAt : 0,
|
|
53
|
-
};
|
|
54
83
|
// prefer a live session, then the most recent one
|
|
55
84
|
if (!best || (best.archived && !s.archived) || (best.archived === s.archived && s.lastActivityAt > best.lastActivityAt))
|
|
56
85
|
best = s;
|
|
57
86
|
}
|
|
58
87
|
return best;
|
|
59
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The CLI sessions the human archived in the Claude desktop app, as a test for a session id
|
|
91
|
+
* (full or a prefix of 6+ characters). Such a session never takes another turn, however its log
|
|
92
|
+
* ends. A CLI session that a live desktop session also runs is not archived.
|
|
93
|
+
*/
|
|
94
|
+
export function archivedSessions(dir = desktopSessionsDir()) {
|
|
95
|
+
const archived = new Set();
|
|
96
|
+
const live = new Set();
|
|
97
|
+
for (const s of desktopSessions(dir))
|
|
98
|
+
for (const id of s.ids)
|
|
99
|
+
(s.archived ? archived : live).add(id);
|
|
100
|
+
for (const id of live)
|
|
101
|
+
archived.delete(id);
|
|
102
|
+
const ids = [...archived];
|
|
103
|
+
return (session) => {
|
|
104
|
+
if (!session || session.length < 6)
|
|
105
|
+
return false;
|
|
106
|
+
if (archived.has(session))
|
|
107
|
+
return true;
|
|
108
|
+
return ids.some((id) => id.startsWith(session));
|
|
109
|
+
};
|
|
110
|
+
}
|
|
60
111
|
/** A link that opens the session in the Claude desktop app. */
|
|
61
112
|
export function desktopSessionUrl(s) {
|
|
62
113
|
return `claude://code/continue?session=${encodeURIComponent(s.id)}&source=parawatch`;
|
package/dist/core/format.js
CHANGED
|
@@ -321,6 +321,9 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
|
|
|
321
321
|
const all = blocks.flatMap((b) => b.rows);
|
|
322
322
|
const nameW = Math.min(Math.max(24, Math.floor(v.width * 0.4)), Math.max(12, ...all.map((row) => textWidth(row.prefix) + 2 + textWidth(row.run?.title ?? ""))));
|
|
323
323
|
const wideW = v.wide ? 24 : 0;
|
|
324
|
+
// the machine a run is on gets a column of its own, so a long title cannot cut it off
|
|
325
|
+
const devW = Math.min(12, Math.max(0, ...all.map((row) => (row.run?.device ? textWidth(row.run.device) : 0))));
|
|
326
|
+
const devCol = devW ? devW + 2 : 0;
|
|
324
327
|
const out = [];
|
|
325
328
|
for (const { name, rows } of blocks) {
|
|
326
329
|
if (name === OTHER)
|
|
@@ -342,12 +345,13 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
|
|
|
342
345
|
const label = r.title + (r.repeats ? ` ×${r.repeats + 1}` : "") + (r.prs?.length ? ` (${r.prs.length} PR)` : "");
|
|
343
346
|
const title = paint(v, pad(fit(label, room), room), ...titlePaint);
|
|
344
347
|
const age = paint(v, ago(r.lastActivity, now).padStart(4), "dim");
|
|
348
|
+
const dev = devW ? " " + paint(v, pad(fit(r.device ?? "", devW), devW), "cyan") : "";
|
|
345
349
|
const src = (r.projectSource && SOURCE_NOTE[r.projectSource]) || "";
|
|
346
350
|
const wide = v.wide ? " " + paint(v, pad(fit(`${r.kind === "subagent" ? r.model ?? "subagent" : r.runtime} ${r.id.slice(0, 8)}${src ? " " + src : ""}`, wideW - 2), wideW - 2), "dim") : "";
|
|
347
|
-
const restW = v.width - nameW - 2 - 4 - wideW - 2;
|
|
351
|
+
const restW = v.width - nameW - 2 - 4 - devCol - wideW - 2;
|
|
348
352
|
const whatPaint = r.error || r.state === "stalled" || r.state === "failed" || r.asks?.blocked ? ["red"] : r.asks ? ["yellow"] : ended ? ["dim"] : [];
|
|
349
353
|
const what = restW >= 8 ? " " + paint(v, fit(whatText(r), restW), ...whatPaint) : "";
|
|
350
|
-
out.push(`${lead}${mark} ${title} ${age}${wide}${what}`.trimEnd());
|
|
354
|
+
out.push(`${lead}${mark} ${title} ${age}${dev}${wide}${what}`.trimEnd());
|
|
351
355
|
}
|
|
352
356
|
}
|
|
353
357
|
return out.join("\n");
|
package/dist/core/github.js
CHANGED
|
@@ -7,7 +7,7 @@ import { execFile, execFileSync } from "node:child_process";
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { UserError } from "./ops.js";
|
|
10
|
-
import { appendLocked, homeDir, withLock } from "./store.js";
|
|
10
|
+
import { appendLocked, deviceName, homeDir, withLock } from "./store.js";
|
|
11
11
|
export const DEFAULT_STALL_HOURS = {
|
|
12
12
|
review: 24,
|
|
13
13
|
"no reviewer": 4,
|
|
@@ -307,6 +307,23 @@ export function nextCache(prev, fresh, hosts, at) {
|
|
|
307
307
|
delete since[k];
|
|
308
308
|
return { cache: { fetchedAt: at, hosts, prs, since }, events };
|
|
309
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Drop news another machine already reported. This machine's reading spans from its previous
|
|
312
|
+
* reading (`since`) to now; the same change on the same PR that another machine wrote in that
|
|
313
|
+
* span is the one this machine just noticed, maybe long after. A machine that reads GitHub for
|
|
314
|
+
* the first time in hours would otherwise tell every agent again what they already handled.
|
|
315
|
+
*/
|
|
316
|
+
export function dropReported(fresh, log, since, me = deviceName()) {
|
|
317
|
+
if (!since)
|
|
318
|
+
return fresh;
|
|
319
|
+
const seen = new Set();
|
|
320
|
+
for (const e of log)
|
|
321
|
+
if (e.type === "pr.change" && e.dev && e.dev !== me && e.ts >= since)
|
|
322
|
+
seen.add(`${e.pr.key}|${e.change}|${e.by ?? ""}`);
|
|
323
|
+
if (!seen.size)
|
|
324
|
+
return fresh;
|
|
325
|
+
return fresh.filter((b) => b.type !== "pr.change" || !seen.has(`${b.pr.key}|${b.change}|${b.by ?? ""}`));
|
|
326
|
+
}
|
|
310
327
|
// ------------------------------------------------------------------ sync
|
|
311
328
|
function fetchingPath() {
|
|
312
329
|
return path.join(homeDir(), "github.fetching");
|
|
@@ -386,7 +403,7 @@ export async function syncGithub(opts) {
|
|
|
386
403
|
const { cache, events } = nextCache(latest, fresh, hosts, started);
|
|
387
404
|
if (errors.length)
|
|
388
405
|
cache.error = errors.join("; ");
|
|
389
|
-
appendLocked(() => events);
|
|
406
|
+
appendLocked((log) => dropReported(events, log, latest.fetchedAt));
|
|
390
407
|
writeCache(cache);
|
|
391
408
|
return cache;
|
|
392
409
|
});
|
package/dist/core/integrate.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { furthest, inboxFrom, readInbox, readState, sameSession, sessionConsumer } from "./ops.js";
|
|
5
5
|
import { homeDir, readCursor, readEvents } from "./store.js";
|
|
6
6
|
import { inboxLine } from "./format.js";
|
|
7
|
+
import { insidePath } from "./paths.js";
|
|
7
8
|
export const BEGIN = "<!-- parawatch:begin -->";
|
|
8
9
|
export const END = "<!-- parawatch:end -->";
|
|
9
10
|
export function instructions(project) {
|
|
@@ -23,7 +24,7 @@ The human follows this work through \`parawatch\`. Use it instead of asking in c
|
|
|
23
24
|
- Stuck until the human does something only they can do (install, sudo, sign in): \`parawatch block --project ${p} "what to do"\`
|
|
24
25
|
- Something only the human can do, but nothing waits on it now: \`parawatch todo --project ${p} "what to do"\`
|
|
25
26
|
- Put every question for the human on a card, not only in your reply: a question left in chat is easy to miss.
|
|
26
|
-
- After opening a card or linking a pull request, keep a background wait armed: \`parawatch wait
|
|
27
|
+
- After opening a card or linking a pull request, keep a background wait armed: \`parawatch wait\`. It prints the human's answers to your cards and news on your pull requests and exits; act on them, \`parawatch close <card> --note "what you did"\`, then arm it again. A lead watching the whole project waits with \`parawatch wait --project ${p}\`.
|
|
27
28
|
- A long job with no agent log (a bake, a benchmark): \`parawatch run start --project ${p} "title" --heartbeat <seconds> [--log <file>]\`, \`parawatch run note <id> "..."\`, \`parawatch run end <id>\`.
|
|
28
29
|
- Never answer a card yourself. \`parawatch status\` shows everything that is open.
|
|
29
30
|
${END}`;
|
|
@@ -97,23 +98,28 @@ export function instructionState(file) {
|
|
|
97
98
|
export function hookContext(input, project) {
|
|
98
99
|
const events = readEvents();
|
|
99
100
|
const state = readState(events);
|
|
101
|
+
const session = input.session_id;
|
|
100
102
|
let name = project;
|
|
101
|
-
if (!name &&
|
|
102
|
-
name = state.claims.find((c) =>
|
|
103
|
+
if (!name && session)
|
|
104
|
+
name = state.claims.find((c) => sameSession(session, c.session))?.project;
|
|
103
105
|
if (!name && input.cwd) {
|
|
104
106
|
for (const p of state.projects.values())
|
|
105
|
-
if (p.paths.some((x) => input.cwd
|
|
107
|
+
if (p.paths.some((x) => insidePath(input.cwd, x)))
|
|
106
108
|
name = p.name;
|
|
107
109
|
}
|
|
108
|
-
if (!name)
|
|
110
|
+
if (!name && !session)
|
|
109
111
|
return undefined;
|
|
110
|
-
|
|
112
|
+
// A session reads its own news with its own cursor; one still waiting on its project moves the
|
|
113
|
+
// project's cursor instead, so whichever is further says what it has handled.
|
|
114
|
+
const items = session
|
|
115
|
+
? inboxFrom(events, furthest(readCursor(sessionConsumer(session)), name ? readCursor(name) : {}), { session, project: name }, state)
|
|
116
|
+
: inboxFrom(events, readCursor(name), { project: name }, state);
|
|
111
117
|
if (!items.length)
|
|
112
118
|
return undefined;
|
|
113
119
|
const cards = items.filter((i) => i.kind !== "pr").length;
|
|
114
120
|
const prs = items.length - cards;
|
|
115
121
|
const what = [cards ? `${cards} answer(s) on cards` : "", prs ? `${prs} pull request update(s)` : ""].filter(Boolean).join(" and ");
|
|
116
|
-
return [`Parawatch: ${what} for ${name} that you have not handled yet.`, ...items.map(inboxLine), cards ? `Act on them, then \`parawatch close <card>\`.` : `Act on them.`].join("\n");
|
|
122
|
+
return [`Parawatch: ${what}${name ? ` for ${name}` : ""} that you have not handled yet.`, ...items.map(inboxLine), cards ? `Act on them, then \`parawatch close <card>\`.` : `Act on them.`].join("\n");
|
|
117
123
|
}
|
|
118
124
|
/** Best-effort desktop notification. Never throws, never blocks. */
|
|
119
125
|
/** The desktop app writes its pid here while it runs, so the CLI leaves notifications to it. */
|
package/dist/core/ops.js
CHANGED
|
@@ -2,10 +2,11 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { UserError } from "./errors.js";
|
|
4
4
|
import { advance, appendEvent, deviceName, eventsDir, homeDir, isAfter, readCursor, readEvents, sharedDir, writeCursor } from "./store.js";
|
|
5
|
-
import {
|
|
5
|
+
import { archivedSessions } from "./desktop.js";
|
|
6
|
+
import { findId, fold, nextId, sessionTitle } from "./state.js";
|
|
6
7
|
import { pathMap } from "./sync.js";
|
|
7
8
|
import { CARD_KINDS, HUMAN, PR_CHANGES } from "./types.js";
|
|
8
|
-
export { UserError };
|
|
9
|
+
export { UserError, sessionTitle };
|
|
9
10
|
/** Current state, with other machines' paths made local. */
|
|
10
11
|
export function readState(events = readEvents()) {
|
|
11
12
|
return fold(events, pathMap());
|
|
@@ -18,9 +19,21 @@ function cleanList(xs) {
|
|
|
18
19
|
const out = (xs ?? []).map((x) => x.trim()).filter(Boolean);
|
|
19
20
|
return out.length ? out : undefined;
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* An attachment or a thing to open, as stored. A URL, and a path that is absolute on either
|
|
24
|
+
* system (`/Users/...`, `C:\...`, `\\server\share`), is kept as written: it may name a place on
|
|
25
|
+
* another machine, and resolving it here would root it on this one (`E:\Users\...` on Windows,
|
|
26
|
+
* `<cwd>/C:\...` on a Mac). Only a relative path is resolved.
|
|
27
|
+
*/
|
|
28
|
+
export function storedPath(x) {
|
|
29
|
+
if (/^[a-z]+:\/\//i.test(x))
|
|
30
|
+
return x;
|
|
31
|
+
if (path.posix.isAbsolute(x) || path.win32.isAbsolute(x))
|
|
32
|
+
return x;
|
|
33
|
+
return path.resolve(x);
|
|
34
|
+
}
|
|
21
35
|
function absPaths(xs) {
|
|
22
|
-
|
|
23
|
-
return cleanList(xs)?.map((x) => (/^[a-z]+:\/\//i.test(x) ? x : path.resolve(x)));
|
|
36
|
+
return cleanList(xs)?.map(storedPath);
|
|
24
37
|
}
|
|
25
38
|
export function addProject(name, paths) {
|
|
26
39
|
if (!/^[A-Za-z0-9._-]+$/.test(name))
|
|
@@ -57,13 +70,6 @@ export function parseWhen(s) {
|
|
|
57
70
|
throw new UserError(`"${s}" is not a date (use YYYY-MM-DD)`);
|
|
58
71
|
return d.toISOString();
|
|
59
72
|
}
|
|
60
|
-
/** The claimed name of a session (claims may hold an id prefix). */
|
|
61
|
-
export function sessionTitle(claims, session) {
|
|
62
|
-
if (!session)
|
|
63
|
-
return undefined;
|
|
64
|
-
const c = claims.find((x) => session.startsWith(x.session) || x.session.startsWith(session));
|
|
65
|
-
return c?.title;
|
|
66
|
-
}
|
|
67
73
|
function checkKind(kind) {
|
|
68
74
|
if (!CARD_KINDS.includes(kind))
|
|
69
75
|
throw new UserError(`kind must be one of ${CARD_KINDS.join(", ")}`);
|
|
@@ -273,6 +279,38 @@ export function prEvent(pr, change, opts = {}) {
|
|
|
273
279
|
throw new UserError(`change must be one of ${PR_CHANGES.join(", ")}`);
|
|
274
280
|
appendEvent(() => ({ type: "pr.change", pr, change: change, by: opts.by, text: opts.text?.trim() || undefined, title: opts.title, source: "external" }));
|
|
275
281
|
}
|
|
282
|
+
/** Whether two session ids name the same session. A claim or a link may hold a short prefix. */
|
|
283
|
+
export function sameSession(a, b) {
|
|
284
|
+
return !!a && !!b && (a.startsWith(b) || b.startsWith(a));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Whether a session has ended: it said so with `claim --done`, or the human archived it in the
|
|
288
|
+
* Claude desktop app. Cards and pull requests it owned then belong to its project, so an answer
|
|
289
|
+
* still reaches a session waiting there. The desktop app is read only when asked about a session
|
|
290
|
+
* that has not claimed done.
|
|
291
|
+
*/
|
|
292
|
+
export function endedSessions(state, isArchived) {
|
|
293
|
+
let archived = isArchived;
|
|
294
|
+
return (session) => {
|
|
295
|
+
const claim = state.claims.find((c) => sameSession(c.session, session));
|
|
296
|
+
if (claim?.done)
|
|
297
|
+
return true;
|
|
298
|
+
archived ??= archivedSessions();
|
|
299
|
+
return archived(session);
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/** Whether something owned by `owner` in `project` is for a reader with this filter. */
|
|
303
|
+
function inScope(owner, project, f, ended) {
|
|
304
|
+
if (f.session) {
|
|
305
|
+
if (owner && sameSession(owner, f.session))
|
|
306
|
+
return true;
|
|
307
|
+
// what a session that has ended owned is the project's now
|
|
308
|
+
if (owner && !ended(owner))
|
|
309
|
+
return false;
|
|
310
|
+
return !!f.project && project === f.project;
|
|
311
|
+
}
|
|
312
|
+
return !f.project || project === f.project;
|
|
313
|
+
}
|
|
276
314
|
/** Two machines that both read GitHub report the same change; the later report is an echo. */
|
|
277
315
|
const ECHO_MS = 30 * 60_000;
|
|
278
316
|
/**
|
|
@@ -285,6 +323,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
285
323
|
const out = [];
|
|
286
324
|
const reported = new Map();
|
|
287
325
|
const answerNo = new Map();
|
|
326
|
+
const ended = filter.ended ?? endedSessions(state);
|
|
288
327
|
for (const ev of events) {
|
|
289
328
|
if (ev.type === "pr.change") {
|
|
290
329
|
const k = `${ev.pr.key}|${ev.change}|${ev.text ?? ""}`;
|
|
@@ -296,7 +335,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
296
335
|
if (!isAfter(ev, pos))
|
|
297
336
|
continue;
|
|
298
337
|
const link = state.prLinks.get(ev.pr.key);
|
|
299
|
-
if (!link || filter.card || (
|
|
338
|
+
if (!link || filter.card || !inScope(link.session, link.project, filter, ended))
|
|
300
339
|
continue;
|
|
301
340
|
out.push({ seq: ev.seq, dev: ev.dev, 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 } });
|
|
302
341
|
continue;
|
|
@@ -316,7 +355,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
316
355
|
const card = state.cards.get(ev.card);
|
|
317
356
|
if (!card || card.status === "closed")
|
|
318
357
|
continue;
|
|
319
|
-
if (
|
|
358
|
+
if (!inScope(card.session, card.project, filter, ended))
|
|
320
359
|
continue;
|
|
321
360
|
if (filter.card && card.id !== filter.card)
|
|
322
361
|
continue;
|
|
@@ -432,13 +471,58 @@ export function listeners(now = Date.now()) {
|
|
|
432
471
|
}
|
|
433
472
|
return out;
|
|
434
473
|
}
|
|
435
|
-
/**
|
|
436
|
-
|
|
437
|
-
|
|
474
|
+
/**
|
|
475
|
+
* The waits an answer to this card would wake right now. A card whose session has ended reaches
|
|
476
|
+
* the session waits in its project, as an unowned card does. A snapshot's card says so itself
|
|
477
|
+
* (`ownerEnded`); otherwise `ended` tells.
|
|
478
|
+
*/
|
|
479
|
+
export function listenersFor(card, ls, ended) {
|
|
480
|
+
let gone = card.ownerEnded;
|
|
481
|
+
const ownerGone = () => (gone ??= !!card.session && (ended ?? endedSessions(readState()))(card.session));
|
|
482
|
+
return ls.filter((l) => l.card
|
|
483
|
+
? l.card === card.id
|
|
484
|
+
: l.scope === "session"
|
|
485
|
+
? card.session && sameSession(l.session, card.session)
|
|
486
|
+
? true
|
|
487
|
+
: (!card.session || ownerGone()) && !!l.project && l.project === card.project
|
|
488
|
+
: !l.project || l.project === card.project);
|
|
438
489
|
}
|
|
439
490
|
export function consumerName(opts) {
|
|
440
491
|
return opts.as || opts.project || (opts.card ? `card-${opts.card}` : "default");
|
|
441
492
|
}
|
|
493
|
+
/** The cursor a session reads with when it waits for its own news. */
|
|
494
|
+
export function sessionConsumer(session) {
|
|
495
|
+
return `session-${session.replace(/[^A-Za-z0-9._-]/g, "_")}`;
|
|
496
|
+
}
|
|
497
|
+
/** Per device, the further of two positions. */
|
|
498
|
+
export function furthest(a, b) {
|
|
499
|
+
const out = { ...a };
|
|
500
|
+
for (const [d, seq] of Object.entries(b))
|
|
501
|
+
if (seq > (out[d] ?? 0))
|
|
502
|
+
out[d] = seq;
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* What a wait or an inbox reads. `--card` and `--project` read what they name, with a cursor of
|
|
507
|
+
* that name. Called from an agent session without either, it reads that session's own cards and
|
|
508
|
+
* pull requests, plus unowned ones in the project the session claimed, with a cursor of its own:
|
|
509
|
+
* sessions in one project neither wake for each other's news nor take it from each other.
|
|
510
|
+
* Outside a session, it reads everything.
|
|
511
|
+
*/
|
|
512
|
+
export function inboxScope(opts, session) {
|
|
513
|
+
if (opts.card || opts.project || !session)
|
|
514
|
+
return { consumer: consumerName(opts), filter: { project: opts.project, card: opts.card } };
|
|
515
|
+
const claim = readState().claims.find((c) => sameSession(session, c.session));
|
|
516
|
+
const consumer = opts.as || sessionConsumer(session);
|
|
517
|
+
// A session that used to wait on its project starts where that wait stopped, so moving to its
|
|
518
|
+
// own cursor does not bring back news it has already handled.
|
|
519
|
+
if (claim && !Object.keys(readCursor(consumer)).length) {
|
|
520
|
+
const from = readCursor(claim.project);
|
|
521
|
+
if (Object.keys(from).length)
|
|
522
|
+
writeCursor(consumer, from);
|
|
523
|
+
}
|
|
524
|
+
return { consumer, filter: { session, project: claim?.project } };
|
|
525
|
+
}
|
|
442
526
|
export function readInbox(consumer, filter, move) {
|
|
443
527
|
const events = readEvents();
|
|
444
528
|
const pos = readCursor(consumer);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const WINDOWS = process.platform === "win32";
|
|
4
|
+
/**
|
|
5
|
+
* The real spelling of a path, for comparing: symlinks and Windows short names (`RUNNER~1`)
|
|
6
|
+
* resolved, through the deepest part that exists. On Windows also lower case with `\` only, since
|
|
7
|
+
* the file system ignores case and logs mix `/` and `\`.
|
|
8
|
+
*/
|
|
9
|
+
export function canonicalPath(p) {
|
|
10
|
+
let rest = "";
|
|
11
|
+
let head = path.resolve(p);
|
|
12
|
+
let out = head;
|
|
13
|
+
for (;;) {
|
|
14
|
+
try {
|
|
15
|
+
out = path.join(fs.realpathSync.native(head), rest);
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
const up = path.dirname(head);
|
|
20
|
+
if (up === head)
|
|
21
|
+
break;
|
|
22
|
+
rest = path.join(path.basename(head), rest);
|
|
23
|
+
head = up;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return WINDOWS ? out.replace(/\//g, "\\").toLowerCase() : out;
|
|
27
|
+
}
|
|
28
|
+
function within(child, parent) {
|
|
29
|
+
const rel = path.relative(parent, child);
|
|
30
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether `child` is `parent` or inside it, as written or once both are resolved
|
|
34
|
+
* (`canonicalPath`). Pass a canonical form when it is already known, to skip the file system.
|
|
35
|
+
*/
|
|
36
|
+
export function insidePath(child, parent, canon) {
|
|
37
|
+
if (within(path.resolve(child), path.resolve(parent)))
|
|
38
|
+
return true;
|
|
39
|
+
return within(canon?.child ?? canonicalPath(child), canon?.parent ?? canonicalPath(parent));
|
|
40
|
+
}
|
|
41
|
+
/** Whether two paths name the same folder. */
|
|
42
|
+
export function samePath(a, b) {
|
|
43
|
+
return path.resolve(a) === path.resolve(b) || canonicalPath(a) === canonicalPath(b);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a text (a prompt) names this folder: as written, or with `\` and `/` swapped, and on
|
|
47
|
+
* Windows ignoring case.
|
|
48
|
+
*/
|
|
49
|
+
export function namesPath(text, dir) {
|
|
50
|
+
if (text.includes(dir))
|
|
51
|
+
return true;
|
|
52
|
+
const hay = WINDOWS ? text.toLowerCase() : text;
|
|
53
|
+
const d = WINDOWS ? dir.toLowerCase() : dir;
|
|
54
|
+
return hay.includes(d.replace(/\\/g, "/")) || hay.includes(d.replace(/\//g, "\\"));
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=paths.js.map
|
package/dist/core/runs.js
CHANGED
|
@@ -3,6 +3,9 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { scanAll } from "../adapters/index.js";
|
|
5
5
|
import { ACTIVITY_MINUTES, activeMinutes } from "../adapters/jsonl.js";
|
|
6
|
+
import { archivedSessions } from "./desktop.js";
|
|
7
|
+
import { canonicalPath, insidePath, namesPath, samePath } from "./paths.js";
|
|
8
|
+
import { deviceName, sharedDir } from "./store.js";
|
|
6
9
|
/** All checkouts of a project path: the path itself plus its git worktrees. */
|
|
7
10
|
function rootsOf(p, cache) {
|
|
8
11
|
const hit = cache.get(p);
|
|
@@ -13,7 +16,7 @@ function rootsOf(p, cache) {
|
|
|
13
16
|
const git = (args) => execFileSync("git", ["-C", p, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
|
|
14
17
|
// a folder inside a bigger repo (a notes vault, a monorepo package) must not claim the whole repo
|
|
15
18
|
const top = git(["rev-parse", "--show-toplevel"]).trim();
|
|
16
|
-
if (
|
|
19
|
+
if (!samePath(top, p)) {
|
|
17
20
|
cache.set(p, roots);
|
|
18
21
|
return roots;
|
|
19
22
|
}
|
|
@@ -32,23 +35,23 @@ function rootsOf(p, cache) {
|
|
|
32
35
|
cache.set(p, roots);
|
|
33
36
|
return roots;
|
|
34
37
|
}
|
|
35
|
-
function inside(child, parent) {
|
|
36
|
-
const rel = path.relative(parent, child);
|
|
37
|
-
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
38
|
-
}
|
|
39
38
|
function matchers(projects) {
|
|
40
39
|
const cache = new Map();
|
|
41
|
-
return projects.map((p) => ({
|
|
40
|
+
return projects.map((p) => ({
|
|
41
|
+
project: p.name,
|
|
42
|
+
roots: p.paths.flatMap((x) => rootsOf(x, cache)).map((r) => ({ path: r, canonical: canonicalPath(r) })),
|
|
43
|
+
}));
|
|
42
44
|
}
|
|
43
45
|
/** The project whose checkout holds this directory; the deepest root wins. */
|
|
44
46
|
function projectForCwd(cwd, ms) {
|
|
45
47
|
if (!cwd)
|
|
46
48
|
return undefined;
|
|
49
|
+
const child = canonicalPath(cwd);
|
|
47
50
|
let best;
|
|
48
51
|
for (const m of ms) {
|
|
49
52
|
for (const r of m.roots) {
|
|
50
|
-
if (
|
|
51
|
-
best = { project: m.project, depth: r.length };
|
|
53
|
+
if (insidePath(cwd, r.path, { child, parent: r.canonical }) && (!best || r.path.length > best.depth))
|
|
54
|
+
best = { project: m.project, depth: r.path.length };
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
return best?.project;
|
|
@@ -60,7 +63,7 @@ function projectForPrompt(prompt, ms) {
|
|
|
60
63
|
const hits = new Set();
|
|
61
64
|
for (const m of ms) {
|
|
62
65
|
for (const r of m.roots)
|
|
63
|
-
if (prompt.
|
|
66
|
+
if (namesPath(prompt, r.path))
|
|
64
67
|
hits.add(m.project);
|
|
65
68
|
}
|
|
66
69
|
return hits.size === 1 ? [...hits][0] : undefined;
|
|
@@ -96,27 +99,31 @@ function claimFor(id, state) {
|
|
|
96
99
|
* unless it only edits files of another project; its parent's project; the project whose files it
|
|
97
100
|
* edits most; a project path named in its first prompt.
|
|
98
101
|
*/
|
|
102
|
+
function applyClaim(r, claim) {
|
|
103
|
+
r.project = claim.project;
|
|
104
|
+
r.projectSource = "claim";
|
|
105
|
+
r.task = claim.task ?? r.task;
|
|
106
|
+
r.role = claim.role ?? r.role;
|
|
107
|
+
if (claim.title)
|
|
108
|
+
r.title = claim.title;
|
|
109
|
+
if (claim.kind === "scheduled")
|
|
110
|
+
r.scheduled = true;
|
|
111
|
+
// done until someone writes to the session again
|
|
112
|
+
if (claim.done && claim.at && (!r.lastPromptAt || r.lastPromptAt < claim.at)) {
|
|
113
|
+
r.done = claim.done;
|
|
114
|
+
// a session that says it is finished is not stuck, even if its log ends mid-turn
|
|
115
|
+
if (r.state === "idle" || r.state === "stalled")
|
|
116
|
+
r.state = "done";
|
|
117
|
+
r.asks = undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
99
120
|
export function attribute(runs, state) {
|
|
100
121
|
const ms = matchers([...state.projects.values()]);
|
|
101
122
|
const byId = new Map(runs.map((r) => [r.id, r]));
|
|
102
123
|
for (const r of runs) {
|
|
103
124
|
const claim = claimFor(r.id, state);
|
|
104
125
|
if (claim) {
|
|
105
|
-
r
|
|
106
|
-
r.projectSource = "claim";
|
|
107
|
-
r.task = claim.task ?? r.task;
|
|
108
|
-
r.role = claim.role ?? r.role;
|
|
109
|
-
if (claim.title)
|
|
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
|
-
}
|
|
126
|
+
applyClaim(r, claim);
|
|
120
127
|
continue;
|
|
121
128
|
}
|
|
122
129
|
const here = projectForCwd(r.cwd, ms);
|
|
@@ -154,6 +161,29 @@ export function attribute(runs, state) {
|
|
|
154
161
|
}
|
|
155
162
|
return runs;
|
|
156
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* A Claude Code session the human archived in the Claude desktop app has ended, even when its log
|
|
166
|
+
* stops mid-turn (archived while a tool ran, say), and so have its subagents.
|
|
167
|
+
*/
|
|
168
|
+
export function endArchived(runs, isArchived) {
|
|
169
|
+
const ended = new Set();
|
|
170
|
+
for (const r of runs) {
|
|
171
|
+
if (r.runtime !== "claude-code" || r.kind !== "session" || r.device || !isArchived(r.id))
|
|
172
|
+
continue;
|
|
173
|
+
ended.add(r.id);
|
|
174
|
+
}
|
|
175
|
+
for (const r of runs) {
|
|
176
|
+
if (!ended.has(r.id) && !(r.parent && ended.has(r.parent)))
|
|
177
|
+
continue;
|
|
178
|
+
if (r.state === "failed")
|
|
179
|
+
continue;
|
|
180
|
+
r.state = "done";
|
|
181
|
+
if (ended.has(r.id))
|
|
182
|
+
r.done ??= "archived in the Claude app";
|
|
183
|
+
r.asks = undefined;
|
|
184
|
+
}
|
|
185
|
+
return runs;
|
|
186
|
+
}
|
|
157
187
|
/**
|
|
158
188
|
* Fold repeated runs of a scheduled task into the newest one, with a count. Runs claimed as
|
|
159
189
|
* scheduled group by project and task; unclaimed top-level sessions group by an identical first
|
|
@@ -247,6 +277,92 @@ export function declaredRuns(state, now = Date.now()) {
|
|
|
247
277
|
};
|
|
248
278
|
});
|
|
249
279
|
}
|
|
280
|
+
// ---------------------------------------------------------------- other machines
|
|
281
|
+
/** How often a machine rewrites its runs for the others, and when its summary counts as gone. */
|
|
282
|
+
const SHARE_EVERY_MS = 30_000;
|
|
283
|
+
export const SHARED_RUNS_GONE_MS = 15 * 60_000;
|
|
284
|
+
const SHARED_RUNS_FILE = /^([a-z0-9][a-z0-9-]{0,31})\.json$/;
|
|
285
|
+
function sharedRunsDir() {
|
|
286
|
+
const s = sharedDir();
|
|
287
|
+
return s ? path.join(s, "runs") : undefined;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Write the runs read from this machine's agent logs where the other machines read them, at most
|
|
291
|
+
* every half minute. The logs themselves stay here; only the summary travels.
|
|
292
|
+
*/
|
|
293
|
+
export function shareRuns(runs, now = Date.now()) {
|
|
294
|
+
if (!shareRunsDue(now))
|
|
295
|
+
return;
|
|
296
|
+
const dir = sharedRunsDir();
|
|
297
|
+
const me = deviceName();
|
|
298
|
+
const file = path.join(dir, `${me}.json`);
|
|
299
|
+
// the folders it edited are paths on this machine, and only guide attribution here
|
|
300
|
+
const slim = runs.map(({ touched: _t, ...r }) => ({ ...r, prompt: r.prompt?.slice(0, 300) }));
|
|
301
|
+
const body = { v: 1, device: me, at: new Date(now).toISOString(), runs: slim };
|
|
302
|
+
try {
|
|
303
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
304
|
+
const tmp = path.join(dir, `.${me}.${process.pid}.tmp`);
|
|
305
|
+
fs.writeFileSync(tmp, JSON.stringify(body) + "\n");
|
|
306
|
+
fs.renameSync(tmp, file);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// the folder may be busy syncing; the next reading writes it again
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Whether this machine's summary for the others is due (a shared folder, and none written lately). */
|
|
313
|
+
export function shareRunsDue(now = Date.now()) {
|
|
314
|
+
const dir = sharedRunsDir();
|
|
315
|
+
if (!dir)
|
|
316
|
+
return false;
|
|
317
|
+
try {
|
|
318
|
+
return now - fs.statSync(path.join(dir, `${deviceName()}.json`)).mtimeMs >= SHARE_EVERY_MS;
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Runs other machines shared, as they last wrote them, marked with the machine. A machine that
|
|
326
|
+
* has not written for a while is left out: its agents may be long gone. Claims made since are
|
|
327
|
+
* applied here, and the activity chart slides by the summary's age.
|
|
328
|
+
*/
|
|
329
|
+
export function otherRuns(state, now = Date.now()) {
|
|
330
|
+
const dir = sharedRunsDir();
|
|
331
|
+
if (!dir)
|
|
332
|
+
return [];
|
|
333
|
+
const me = deviceName();
|
|
334
|
+
let names = [];
|
|
335
|
+
try {
|
|
336
|
+
names = fs.readdirSync(dir);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
const out = [];
|
|
342
|
+
for (const n of names.sort()) {
|
|
343
|
+
const m = SHARED_RUNS_FILE.exec(n);
|
|
344
|
+
if (!m || m[1] === me)
|
|
345
|
+
continue;
|
|
346
|
+
try {
|
|
347
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, n), "utf8"));
|
|
348
|
+
const at = Date.parse(s.at);
|
|
349
|
+
if (!Number.isFinite(at) || now - at > SHARED_RUNS_GONE_MS || !Array.isArray(s.runs))
|
|
350
|
+
continue;
|
|
351
|
+
const shift = Math.max(0, Math.floor((now - at) / 60_000));
|
|
352
|
+
for (const r of s.runs) {
|
|
353
|
+
const run = { ...r, device: m[1], activity: r.activity?.map((x) => x + shift).filter((x) => x < ACTIVITY_MINUTES) };
|
|
354
|
+
const claim = claimFor(run.id, state);
|
|
355
|
+
if (claim)
|
|
356
|
+
applyClaim(run, claim);
|
|
357
|
+
out.push(run);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// half synced; read it next time
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
250
366
|
const ENDED = new Set(["done", "idle", "failed"]);
|
|
251
367
|
const RECENT_END_MS = 30 * 60_000;
|
|
252
368
|
const OLD_STALL_MS = 3 * 3_600_000;
|
|
@@ -254,7 +370,11 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
254
370
|
const sinceMs = now - q.sinceHours * 3_600_000;
|
|
255
371
|
// a log file can be touched (title records, snapshots) long after its last real activity
|
|
256
372
|
const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
|
|
257
|
-
let runs = attribute(scanAll({ sinceMs, now }, q.runtimes), state).filter(recent);
|
|
373
|
+
let runs = endArchived(attribute(scanAll({ sinceMs, now }, q.runtimes), state), archivedSessions()).filter(recent);
|
|
374
|
+
// a narrower reading (one runtime) would hide the rest from the other machines
|
|
375
|
+
if (!q.runtimes)
|
|
376
|
+
shareRuns(runs, now);
|
|
377
|
+
runs.push(...otherRuns(state, now).filter(recent));
|
|
258
378
|
runs.push(...declaredRuns(state, now).filter(recent));
|
|
259
379
|
attachPrs(runs, state);
|
|
260
380
|
if (q.project)
|
package/dist/core/snapshot.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { prViews, keyOf, parsePrRef, readCache, githubConfig } from "./github.js";
|
|
2
|
-
import { linkPr, listeners, readState } from "./ops.js";
|
|
2
|
+
import { endedSessions, linkPr, listeners, readState } from "./ops.js";
|
|
3
3
|
import { collectRuns } from "./runs.js";
|
|
4
4
|
import { deviceName, eventFiles, sharedDir } from "./store.js";
|
|
5
5
|
import { SCHEMA_VERSION } from "./types.js";
|
|
@@ -15,7 +15,8 @@ export function snapshot(opts = {}) {
|
|
|
15
15
|
if (linkCreatedPrs(runs, state))
|
|
16
16
|
state = readState();
|
|
17
17
|
const all = [...state.cards.values()].filter((c) => !opts.project || c.project === opts.project);
|
|
18
|
-
const
|
|
18
|
+
const ended = endedSessions(state);
|
|
19
|
+
const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)].map((c) => c.status !== "closed" && c.session && ended(c.session) ? { ...c, ownerEnded: true } : c);
|
|
19
20
|
quietAnsweredAsks(runs, all);
|
|
20
21
|
const cache = readCache();
|
|
21
22
|
const enabled = githubConfig().enabled && (cache.hosts === undefined ? false : cache.hosts.length > 0);
|
package/dist/core/state.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/** The claimed name of a session (claims may hold an id prefix). */
|
|
2
|
+
export function sessionTitle(claims, session) {
|
|
3
|
+
if (!session)
|
|
4
|
+
return undefined;
|
|
5
|
+
const c = claims.find((x) => session.startsWith(x.session) || x.session.startsWith(session));
|
|
6
|
+
return c?.title;
|
|
7
|
+
}
|
|
1
8
|
function mapAll(xs, map, from) {
|
|
2
9
|
return xs && map ? xs.map((x) => map(x, from)) : xs;
|
|
3
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parawatch",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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",
|
|
@@ -74,10 +74,12 @@ News from somewhere GitHub does not show (a merge bot, a ticket system) can be r
|
|
|
74
74
|
|
|
75
75
|
## 4. Waiting for the answer
|
|
76
76
|
|
|
77
|
-
Right after opening cards or linking a pull request, keep exactly one wait armed
|
|
77
|
+
Right after opening cards or linking a pull request, keep exactly one wait armed:
|
|
78
78
|
|
|
79
|
-
- **Claude Code**: run `parawatch wait
|
|
80
|
-
- **Other runtimes**: check `parawatch inbox
|
|
79
|
+
- **Claude Code**: run `parawatch wait` as a background Bash command (`run_in_background: true`). When the human answers, the command exits, its output is the answer, and you are woken. Do not poll, do not sleep-loop, do not start a second wait while one is running.
|
|
80
|
+
- **Other runtimes**: check `parawatch inbox` at the start of every turn.
|
|
81
|
+
|
|
82
|
+
Inside your session, `wait` and `inbox` read only what is yours: answers on cards you opened, news on pull requests linked to you, and cards or pull requests no session owns in the project you claimed. Other sessions in the same project are not woken by your news and cannot take it from you. `-p <project>` reads the whole project instead; leave it to a lead (section 8).
|
|
81
83
|
|
|
82
84
|
Keep working on other things meanwhile. A wait with `--timeout <seconds>` exits with code 2 when nothing came; re-arm it if you still need the answer. While no wait runs, an answer is kept, and the card tells the human that no agent is waiting; it reaches you on your next turn or your next wait.
|
|
83
85
|
|
|
@@ -119,6 +121,7 @@ The session then shows as done, not as idle, and the line goes into the human's
|
|
|
119
121
|
|
|
120
122
|
## 8. Leads and workers
|
|
121
123
|
|
|
124
|
+
- A lead that watches the whole project waits with `parawatch wait -p <project>`.
|
|
122
125
|
- The lead owns the cards for its project. Workers (subagents, `codex exec` jobs, other sessions you start) report back to the lead instead of opening cards, unless the lead told them to ask the human directly.
|
|
123
126
|
- Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
|
|
124
127
|
- Before ending a session, close cards you handled, end runs you started, and claim `--done`.
|