parawatch 0.3.2 → 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 +1 -0
- package/dist/bin/cli.js +2 -1
- package/dist/core/desktop.js +68 -17
- package/dist/core/format.js +7 -3
- package/dist/core/integrate.js +2 -1
- package/dist/core/ops.js +56 -21
- package/dist/core/paths.js +56 -0
- package/dist/core/runs.js +37 -11
- package/dist/core/snapshot.js +3 -2
- package/dist/core/state.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -290,6 +290,7 @@ 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.
|
|
294
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.
|
|
295
296
|
- **Every machine reads GitHub for itself.** The same news is delivered once.
|
package/dist/bin/cli.js
CHANGED
|
@@ -425,8 +425,9 @@ program
|
|
|
425
425
|
}
|
|
426
426
|
if (answered.length) {
|
|
427
427
|
const notes = {};
|
|
428
|
+
// the snapshot already marked the cards whose session has ended
|
|
428
429
|
for (const c of answered)
|
|
429
|
-
if (!listenersFor(c, ls).length)
|
|
430
|
+
if (!listenersFor(c, ls, () => false).length)
|
|
430
431
|
notes[c.id] = "no agent waiting";
|
|
431
432
|
out.push("", head("ANSWERED, WAITING FOR THE AGENT", answered.length));
|
|
432
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)
|
|
@@ -339,15 +342,16 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
|
|
|
339
342
|
const ended = ENDED.has(r.state);
|
|
340
343
|
const titlePaint = r.role === "lead" ? ["bold"] : ended ? ["dim"] : [];
|
|
341
344
|
const mark = paint(v, STATE_MARK[r.state], ...STATE_PAINT[r.state]);
|
|
342
|
-
const label = r.title + (r.repeats ? ` ×${r.repeats + 1}` : "") + (r.prs?.length ? ` (${r.prs.length} PR)` : "")
|
|
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/integrate.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
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) {
|
|
@@ -103,7 +104,7 @@ export function hookContext(input, project) {
|
|
|
103
104
|
name = state.claims.find((c) => sameSession(session, c.session))?.project;
|
|
104
105
|
if (!name && input.cwd) {
|
|
105
106
|
for (const p of state.projects.values())
|
|
106
|
-
if (p.paths.some((x) => input.cwd
|
|
107
|
+
if (p.paths.some((x) => insidePath(input.cwd, x)))
|
|
107
108
|
name = p.name;
|
|
108
109
|
}
|
|
109
110
|
if (!name && !session)
|
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(", ")}`);
|
|
@@ -277,10 +283,32 @@ export function prEvent(pr, change, opts = {}) {
|
|
|
277
283
|
export function sameSession(a, b) {
|
|
278
284
|
return !!a && !!b && (a.startsWith(b) || b.startsWith(a));
|
|
279
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
|
+
}
|
|
280
302
|
/** Whether something owned by `owner` in `project` is for a reader with this filter. */
|
|
281
|
-
function inScope(owner, project, f) {
|
|
282
|
-
if (f.session)
|
|
283
|
-
|
|
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
|
+
}
|
|
284
312
|
return !f.project || project === f.project;
|
|
285
313
|
}
|
|
286
314
|
/** Two machines that both read GitHub report the same change; the later report is an echo. */
|
|
@@ -295,6 +323,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
295
323
|
const out = [];
|
|
296
324
|
const reported = new Map();
|
|
297
325
|
const answerNo = new Map();
|
|
326
|
+
const ended = filter.ended ?? endedSessions(state);
|
|
298
327
|
for (const ev of events) {
|
|
299
328
|
if (ev.type === "pr.change") {
|
|
300
329
|
const k = `${ev.pr.key}|${ev.change}|${ev.text ?? ""}`;
|
|
@@ -306,7 +335,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
306
335
|
if (!isAfter(ev, pos))
|
|
307
336
|
continue;
|
|
308
337
|
const link = state.prLinks.get(ev.pr.key);
|
|
309
|
-
if (!link || filter.card || !inScope(link.session, link.project, filter))
|
|
338
|
+
if (!link || filter.card || !inScope(link.session, link.project, filter, ended))
|
|
310
339
|
continue;
|
|
311
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 } });
|
|
312
341
|
continue;
|
|
@@ -326,7 +355,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
326
355
|
const card = state.cards.get(ev.card);
|
|
327
356
|
if (!card || card.status === "closed")
|
|
328
357
|
continue;
|
|
329
|
-
if (!inScope(card.session, card.project, filter))
|
|
358
|
+
if (!inScope(card.session, card.project, filter, ended))
|
|
330
359
|
continue;
|
|
331
360
|
if (filter.card && card.id !== filter.card)
|
|
332
361
|
continue;
|
|
@@ -442,14 +471,20 @@ export function listeners(now = Date.now()) {
|
|
|
442
471
|
}
|
|
443
472
|
return out;
|
|
444
473
|
}
|
|
445
|
-
/**
|
|
446
|
-
|
|
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));
|
|
447
482
|
return ls.filter((l) => l.card
|
|
448
483
|
? l.card === card.id
|
|
449
484
|
: l.scope === "session"
|
|
450
|
-
? card.session
|
|
451
|
-
?
|
|
452
|
-
: !!l.project && l.project === card.project
|
|
485
|
+
? card.session && sameSession(l.session, card.session)
|
|
486
|
+
? true
|
|
487
|
+
: (!card.session || ownerGone()) && !!l.project && l.project === card.project
|
|
453
488
|
: !l.project || l.project === card.project);
|
|
454
489
|
}
|
|
455
490
|
export function consumerName(opts) {
|
|
@@ -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,8 @@ 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";
|
|
6
8
|
import { deviceName, sharedDir } from "./store.js";
|
|
7
9
|
/** All checkouts of a project path: the path itself plus its git worktrees. */
|
|
8
10
|
function rootsOf(p, cache) {
|
|
@@ -14,7 +16,7 @@ function rootsOf(p, cache) {
|
|
|
14
16
|
const git = (args) => execFileSync("git", ["-C", p, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
|
|
15
17
|
// a folder inside a bigger repo (a notes vault, a monorepo package) must not claim the whole repo
|
|
16
18
|
const top = git(["rev-parse", "--show-toplevel"]).trim();
|
|
17
|
-
if (
|
|
19
|
+
if (!samePath(top, p)) {
|
|
18
20
|
cache.set(p, roots);
|
|
19
21
|
return roots;
|
|
20
22
|
}
|
|
@@ -33,23 +35,23 @@ function rootsOf(p, cache) {
|
|
|
33
35
|
cache.set(p, roots);
|
|
34
36
|
return roots;
|
|
35
37
|
}
|
|
36
|
-
function inside(child, parent) {
|
|
37
|
-
const rel = path.relative(parent, child);
|
|
38
|
-
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
39
|
-
}
|
|
40
38
|
function matchers(projects) {
|
|
41
39
|
const cache = new Map();
|
|
42
|
-
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
|
+
}));
|
|
43
44
|
}
|
|
44
45
|
/** The project whose checkout holds this directory; the deepest root wins. */
|
|
45
46
|
function projectForCwd(cwd, ms) {
|
|
46
47
|
if (!cwd)
|
|
47
48
|
return undefined;
|
|
49
|
+
const child = canonicalPath(cwd);
|
|
48
50
|
let best;
|
|
49
51
|
for (const m of ms) {
|
|
50
52
|
for (const r of m.roots) {
|
|
51
|
-
if (
|
|
52
|
-
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 };
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
return best?.project;
|
|
@@ -61,7 +63,7 @@ function projectForPrompt(prompt, ms) {
|
|
|
61
63
|
const hits = new Set();
|
|
62
64
|
for (const m of ms) {
|
|
63
65
|
for (const r of m.roots)
|
|
64
|
-
if (prompt.
|
|
66
|
+
if (namesPath(prompt, r.path))
|
|
65
67
|
hits.add(m.project);
|
|
66
68
|
}
|
|
67
69
|
return hits.size === 1 ? [...hits][0] : undefined;
|
|
@@ -109,7 +111,8 @@ function applyClaim(r, claim) {
|
|
|
109
111
|
// done until someone writes to the session again
|
|
110
112
|
if (claim.done && claim.at && (!r.lastPromptAt || r.lastPromptAt < claim.at)) {
|
|
111
113
|
r.done = claim.done;
|
|
112
|
-
if
|
|
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")
|
|
113
116
|
r.state = "done";
|
|
114
117
|
r.asks = undefined;
|
|
115
118
|
}
|
|
@@ -158,6 +161,29 @@ export function attribute(runs, state) {
|
|
|
158
161
|
}
|
|
159
162
|
return runs;
|
|
160
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
|
+
}
|
|
161
187
|
/**
|
|
162
188
|
* Fold repeated runs of a scheduled task into the newest one, with a count. Runs claimed as
|
|
163
189
|
* scheduled group by project and task; unclaimed top-level sessions group by an identical first
|
|
@@ -344,7 +370,7 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
344
370
|
const sinceMs = now - q.sinceHours * 3_600_000;
|
|
345
371
|
// a log file can be touched (title records, snapshots) long after its last real activity
|
|
346
372
|
const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
|
|
347
|
-
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);
|
|
348
374
|
// a narrower reading (one runtime) would hide the rest from the other machines
|
|
349
375
|
if (!q.runtimes)
|
|
350
376
|
shareRuns(runs, now);
|
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",
|