parawatch 0.3.2 → 0.3.4
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 +6 -1
- package/dist/adapters/claudeCode.js +94 -4
- package/dist/bin/cli.js +28 -4
- package/dist/core/desktop.js +68 -17
- package/dist/core/format.js +15 -5
- package/dist/core/integrate.js +2 -1
- package/dist/core/ops.js +106 -26
- package/dist/core/paths.js +56 -0
- package/dist/core/runs.js +62 -12
- package/dist/core/sites.js +148 -0
- package/dist/core/snapshot.js +3 -2
- package/dist/core/state.js +22 -1
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +2 -0
package/README.md
CHANGED
|
@@ -121,6 +121,8 @@ parawatch runs --since 12 # agents seen in the last 12 hours
|
|
|
121
121
|
parawatch runs -p game --all # include finished ones
|
|
122
122
|
parawatch runs --wide # with runtime and ids
|
|
123
123
|
parawatch runs --every # each run of a scheduled task, not one row per task
|
|
124
|
+
parawatch run hide <id> # hide a run (6+ characters of its id) until it does something new; `runs --all` still shows it
|
|
125
|
+
parawatch run unhide <id>
|
|
124
126
|
parawatch pr list # open pull requests: what each waits on, since when, whose it is
|
|
125
127
|
parawatch goto api#88 # open the session that owns a pull request
|
|
126
128
|
parawatch digest # what each session did today (--date, --json)
|
|
@@ -231,7 +233,7 @@ parawatch reads agent logs. It never writes to them and never sends them anywher
|
|
|
231
233
|
| Claude Code | `~/.claude/projects/*/<session>.jsonl` and `<session>/subagents/` (or `$CLAUDE_CONFIG_DIR`) | sessions with their title, subagents with their description and model, the tool in flight |
|
|
232
234
|
| Codex | `~/.codex/sessions/` (or `$CODEX_HOME`) | `codex exec` jobs and interactive sessions with their prompt and last tool call; jobs started from a Claude Code session show under it |
|
|
233
235
|
|
|
234
|
-
States: `●` working, `◐` waiting, `○` idle, `✓` done (a finished subagent, or a session that claimed `--done`), `!` stalled (quiet longer than its tool's timeout allows, or a declared job past its heartbeat), `✗` failed.
|
|
236
|
+
States: `●` working, `◐` waiting, `○` idle, `✓` done (a finished subagent, or a session that claimed `--done`), `!` stalled (quiet longer than its tool's timeout allows, counting a waited-on background job's output file as activity, or a declared job past its heartbeat), `✗` failed. A Claude Code run whose turn ended while a background shell job is still running (no completion notice or stop yet) counts as working, not idle or done.
|
|
235
237
|
|
|
236
238
|
A run belongs to a project by, in order: a claim; its working directory, unless it only edits files of another project; its parent's project; the project whose files it edits most; a project path named in its first prompt. Runs that match nothing show under `other`. `--wide` marks the guesses (`~edits`, `~prompt`).
|
|
237
239
|
|
|
@@ -257,6 +259,8 @@ It reads only, reads only files touched since `sinceMs`, and reads only the head
|
|
|
257
259
|
| [`apps/desktop`](apps/desktop/README.md) | The desktop app. Reads and writes the store directly, so it works without the CLI on `PATH`. |
|
|
258
260
|
| [`packages/panel`](packages/panel) | The panel both of them show, as plain DOM, with a harness page for working on it. |
|
|
259
261
|
|
|
262
|
+
The desktop app shows each service's own site icon beside runs and pull requests (Claude Code, Codex, GitHub). It fetches the icon from that service's site, like a browser's favicon, and caches it in the app's data folder for a week. Turn it off in Settings and nothing is fetched. The names and icons belong to their owners, and parawatch is not affiliated with them.
|
|
263
|
+
|
|
260
264
|
Anything else can build on the same two commands:
|
|
261
265
|
|
|
262
266
|
```
|
|
@@ -290,6 +294,7 @@ parawatch sync # this machine, the others, how the sync tool is doing
|
|
|
290
294
|
- **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
295
|
- **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
296
|
- **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.
|
|
297
|
+
- **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
298
|
- **Waits are seen everywhere.** A card on your laptop says whether an agent on the PC is waiting for it.
|
|
294
299
|
- **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
300
|
- **Every machine reads GitHub for itself.** The same news is delivered once.
|
|
@@ -32,6 +32,8 @@ function toolSummary(b) {
|
|
|
32
32
|
function promptText(rec) {
|
|
33
33
|
if (rec.type !== "user" || rec.isMeta === true)
|
|
34
34
|
return undefined;
|
|
35
|
+
if (isObj(rec.origin) && rec.origin.kind === "task-notification")
|
|
36
|
+
return undefined;
|
|
35
37
|
const bs = blocks(rec);
|
|
36
38
|
if (bs.some((b) => b.type === "tool_result"))
|
|
37
39
|
return undefined;
|
|
@@ -40,6 +42,68 @@ function promptText(rec) {
|
|
|
40
42
|
return undefined;
|
|
41
43
|
return text;
|
|
42
44
|
}
|
|
45
|
+
const JOB_STARTED = /(?:running in background with ID|moved to the background \(ID): ([\w-]+)\)?[^\n]{0,80}?\.\s*Output is being written to: (.+?)\.\s+You will be notified/s;
|
|
46
|
+
const JOB_STARTED_NO_PATH = /(?:running in background with ID|moved to the background \(ID): ([\w-]+)/;
|
|
47
|
+
const NOTICE = /<task-notification>([\s\S]*?)<\/task-notification>/g;
|
|
48
|
+
/** `parawatch wait` / `parawatch watch` at a command start: it blocks for the human and prints nothing. */
|
|
49
|
+
const HUMAN_WAIT = /(?:^|&&|;|\||\bnpx\s+)\s*(?:parawatch\s+(?:wait|watch)\b|node\s+\S*cli\.js\s+(?:wait|watch)\b)/;
|
|
50
|
+
const STOP_TOOLS = new Set(["TaskStop", "KillShell", "KillBash"]);
|
|
51
|
+
function tag(xml, name) {
|
|
52
|
+
return new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(xml)?.[1]?.trim();
|
|
53
|
+
}
|
|
54
|
+
/** Text a record carries that may hold a task notice: a user message, a queued command, a queue entry. */
|
|
55
|
+
function noticeText(rec) {
|
|
56
|
+
const parts = [];
|
|
57
|
+
for (const b of blocks(rec))
|
|
58
|
+
if (b.type === "text" && b.text)
|
|
59
|
+
parts.push(b.text);
|
|
60
|
+
const att = rec.attachment;
|
|
61
|
+
if (isObj(att) && typeof att.prompt === "string")
|
|
62
|
+
parts.push(att.prompt);
|
|
63
|
+
if (rec.type === "queue-operation" && typeof rec.content === "string")
|
|
64
|
+
parts.push(rec.content);
|
|
65
|
+
return parts.join("\n");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Background shell jobs a transcript started and has not seen end. A job ends with a completion
|
|
69
|
+
* notice (a status other than running) or a stop tool call. A job that started before the tail
|
|
70
|
+
* began is unknown and not counted.
|
|
71
|
+
*/
|
|
72
|
+
function pendingJobs(recs) {
|
|
73
|
+
const jobs = new Map();
|
|
74
|
+
const commands = new Map();
|
|
75
|
+
const ended = new Set();
|
|
76
|
+
for (const r of recs) {
|
|
77
|
+
if (!isObj(r))
|
|
78
|
+
continue;
|
|
79
|
+
for (const b of blocks(r)) {
|
|
80
|
+
if (b.type === "tool_use") {
|
|
81
|
+
if (b.id && b.name === "Bash")
|
|
82
|
+
commands.set(b.id, str(b.input?.command) ?? "");
|
|
83
|
+
if (STOP_TOOLS.has(String(b.name))) {
|
|
84
|
+
const id = str(b.input?.task_id) || str(b.input?.shell_id) || str(b.input?.bash_id);
|
|
85
|
+
if (id)
|
|
86
|
+
ended.add(id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (b.type === "tool_result") {
|
|
90
|
+
const text = resultText(b);
|
|
91
|
+
const m = JOB_STARTED.exec(text);
|
|
92
|
+
const id = m?.[1] ?? JOB_STARTED_NO_PATH.exec(text)?.[1];
|
|
93
|
+
if (id)
|
|
94
|
+
jobs.set(id, { id, output: m?.[2]?.trim(), command: b.tool_use_id ? commands.get(b.tool_use_id) : undefined });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const n of noticeText(r).matchAll(NOTICE)) {
|
|
98
|
+
const id = tag(n[1], "task-id");
|
|
99
|
+
const status = tag(n[1], "status");
|
|
100
|
+
// monitor events carry no status and do not end a job
|
|
101
|
+
if (id && status && status.toLowerCase() !== "running")
|
|
102
|
+
ended.add(id);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...jobs.values()].filter((j) => !ended.has(j.id) && !(j.command && HUMAN_WAIT.test(j.command.trim())));
|
|
106
|
+
}
|
|
43
107
|
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
44
108
|
const DIR_IN_COMMAND = /(?:^|[;&|]\s*|\s)(?:cd|git\s+-C)\s+("[^"]+"|'[^']+'|[^\s;&|]+)/g;
|
|
45
109
|
const PR_URL = /https?:\/\/[^\s"'<>()]+\/pull\/\d+/g;
|
|
@@ -137,6 +201,7 @@ export function readClaudeTail(recs, kind) {
|
|
|
137
201
|
}
|
|
138
202
|
if (!lastConv)
|
|
139
203
|
return out;
|
|
204
|
+
const jobs = pendingJobs(recs);
|
|
140
205
|
const bs = blocks(lastConv);
|
|
141
206
|
if (lastConv.type === "assistant") {
|
|
142
207
|
const tool = [...bs].reverse().find((b) => b.type === "tool_use");
|
|
@@ -147,6 +212,15 @@ export function readClaudeTail(recs, kind) {
|
|
|
147
212
|
if (typeof t === "number")
|
|
148
213
|
out.pendingTimeoutMs = t;
|
|
149
214
|
}
|
|
215
|
+
else if (jobs.length && bs.some((b) => b.type === "text" && b.text?.trim())) {
|
|
216
|
+
// the turn ended while a background job runs: the run is waiting, not finished
|
|
217
|
+
const j = jobs[jobs.length - 1];
|
|
218
|
+
out.state = "working";
|
|
219
|
+
out.action = oneLine(`waiting for a background job${j.command ? `: ${j.command}` : ` (${j.id})`}`, 100);
|
|
220
|
+
const outputs = jobs.map((x) => x.output).filter((x) => !!x);
|
|
221
|
+
if (outputs.length)
|
|
222
|
+
out.pendingOutputs = outputs;
|
|
223
|
+
}
|
|
150
224
|
else if (bs.some((b) => b.type === "text" && b.text?.trim())) {
|
|
151
225
|
out.state = kind === "subagent" ? "done" : "idle";
|
|
152
226
|
const full = bs.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
|
|
@@ -208,10 +282,26 @@ function readMeta(file) {
|
|
|
208
282
|
}
|
|
209
283
|
}
|
|
210
284
|
const STALL_MS = 20 * 60_000;
|
|
211
|
-
|
|
285
|
+
/** The newest modification time among files that exist on this machine, or undefined. */
|
|
286
|
+
export function newestMtime(files) {
|
|
287
|
+
let newest;
|
|
288
|
+
for (const f of files ?? []) {
|
|
289
|
+
try {
|
|
290
|
+
const m = fs.statSync(f).mtimeMs;
|
|
291
|
+
if (newest === undefined || m > newest)
|
|
292
|
+
newest = m;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// not on this machine, or gone
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return newest;
|
|
299
|
+
}
|
|
300
|
+
/** `lastSeenMs` is other proof of life, such as a background job's output file changing. */
|
|
301
|
+
export function applyStall(run, pendingTimeoutMs, now, lastSeenMs) {
|
|
212
302
|
if (run.state !== "working" || !run.lastActivity)
|
|
213
303
|
return;
|
|
214
|
-
const quiet = now - Date.parse(run.lastActivity);
|
|
304
|
+
const quiet = now - Math.max(Date.parse(run.lastActivity), lastSeenMs ?? 0);
|
|
215
305
|
const allowed = Math.max(STALL_MS, (pendingTimeoutMs ?? 0) + 2 * 60_000);
|
|
216
306
|
if (quiet > allowed)
|
|
217
307
|
run.state = "stalled";
|
|
@@ -290,7 +380,7 @@ function readSession(file, now) {
|
|
|
290
380
|
prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
|
|
291
381
|
activity: logActivity(file, now),
|
|
292
382
|
};
|
|
293
|
-
applyStall(run, tail.pendingTimeoutMs, now);
|
|
383
|
+
applyStall(run, tail.pendingTimeoutMs, now, newestMtime(tail.pendingOutputs));
|
|
294
384
|
return run;
|
|
295
385
|
}
|
|
296
386
|
catch (err) {
|
|
@@ -321,7 +411,7 @@ function readSubagent(file, now) {
|
|
|
321
411
|
prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
|
|
322
412
|
activity: logActivity(file, now),
|
|
323
413
|
};
|
|
324
|
-
applyStall(run, tail.pendingTimeoutMs, now);
|
|
414
|
+
applyStall(run, tail.pendingTimeoutMs, now, newestMtime(tail.pendingOutputs));
|
|
325
415
|
return run;
|
|
326
416
|
}
|
|
327
417
|
catch (err) {
|
package/dist/bin/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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, inboxScope, editCard, endRun, linkPr, listeners, listenersFor, noteRun, openCard, prEvent, findCard, readInbox, readState, removeProject, startListening, startRun, unlinkPr, waitInbox, } from "../core/ops.js";
|
|
5
|
+
import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, commentCard, inboxScope, editCard, endRun, hideRun, unhideRun, 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";
|
|
@@ -109,7 +109,7 @@ cardCommand("todo", "todo", "something only the human can do, but nothing waits
|
|
|
109
109
|
.action((title, o) => finishCard({ kind: "todo", title, ...common(o) }, o.json));
|
|
110
110
|
program
|
|
111
111
|
.command("edit <card>")
|
|
112
|
-
.description("change a card: its kind, wording, task, who asked
|
|
112
|
+
.description("change a card: its kind, wording, task, who asked, when it came up, or (before anyone answered) its action, preview and attachments (an empty value clears)")
|
|
113
113
|
.option("-k, --kind <kind>", "decide, try, block or todo")
|
|
114
114
|
.option("--title <text>", "new title")
|
|
115
115
|
.option("-b, --body <text>", "new body")
|
|
@@ -117,9 +117,24 @@ program
|
|
|
117
117
|
.option("--from <who>", "who asked")
|
|
118
118
|
.option("--session <id>", "the agent session that asked")
|
|
119
119
|
.option("--since <date>", "when the matter first came up (YYYY-MM-DD)")
|
|
120
|
+
.option("--action <text>", "the exact command or step that runs on yes (only before anyone answered)")
|
|
121
|
+
.option("--preview <file>", "a file with the text the action sends; its text is read again (only before anyone answered)")
|
|
122
|
+
.option("--attach <file>", "replace the attachments with these files (repeatable; only before anyone answered)", collect)
|
|
123
|
+
.option("--no-attach", "remove the attachments (only before anyone answered)")
|
|
120
124
|
.option("--json", "print the card as JSON")
|
|
121
125
|
.action((id, o) => {
|
|
122
|
-
const card = editCard(id, {
|
|
126
|
+
const card = editCard(id, {
|
|
127
|
+
kind: o.kind,
|
|
128
|
+
title: o.title,
|
|
129
|
+
body: o.body,
|
|
130
|
+
task: o.task,
|
|
131
|
+
from: o.from,
|
|
132
|
+
session: o.session,
|
|
133
|
+
since: o.since,
|
|
134
|
+
action: o.action,
|
|
135
|
+
preview: o.preview,
|
|
136
|
+
attach: o.attach === false ? [] : o.attach,
|
|
137
|
+
});
|
|
123
138
|
if (o.json)
|
|
124
139
|
print(card);
|
|
125
140
|
else
|
|
@@ -292,6 +307,14 @@ run
|
|
|
292
307
|
.option("--failed", "it failed")
|
|
293
308
|
.option("-n, --note <text>", "how it ended")
|
|
294
309
|
.action((id, o) => endRun(id, o.failed ? "failed" : "done", o.note));
|
|
310
|
+
run
|
|
311
|
+
.command("hide <id>")
|
|
312
|
+
.description("hide a run from the lists until it does something new (id or its first 6+ characters; `runs --all` still shows it)")
|
|
313
|
+
.action((id) => void process.stdout.write(`hidden ${hideRun(id)}\n`));
|
|
314
|
+
run
|
|
315
|
+
.command("unhide <id>")
|
|
316
|
+
.description("show a hidden run again")
|
|
317
|
+
.action((id) => void process.stdout.write(`shown ${unhideRun(id)}\n`));
|
|
295
318
|
program
|
|
296
319
|
.command("claim [session]")
|
|
297
320
|
.description("say which project and task an agent session works on (id prefix, 6+ characters; defaults to the calling session)")
|
|
@@ -425,8 +448,9 @@ program
|
|
|
425
448
|
}
|
|
426
449
|
if (answered.length) {
|
|
427
450
|
const notes = {};
|
|
451
|
+
// the snapshot already marked the cards whose session has ended
|
|
428
452
|
for (const c of answered)
|
|
429
|
-
if (!listenersFor(c, ls).length)
|
|
453
|
+
if (!listenersFor(c, ls, () => false).length)
|
|
430
454
|
notes[c.id] = "no agent waiting";
|
|
431
455
|
out.push("", head("ANSWERED, WAITING FOR THE AGENT", answered.length));
|
|
432
456
|
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
|
@@ -115,6 +115,11 @@ export function cardRows(cards, v = PLAIN, now = Date.now(), indent = "", extra
|
|
|
115
115
|
export function cardLine(c, now = Date.now()) {
|
|
116
116
|
return cardRows([c], PLAIN, now)[0].trimEnd();
|
|
117
117
|
}
|
|
118
|
+
/** Local clock time, as in "14:05". */
|
|
119
|
+
function hhmm(iso) {
|
|
120
|
+
const d = new Date(iso);
|
|
121
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
122
|
+
}
|
|
118
123
|
export function cardDetail(c) {
|
|
119
124
|
const out = [];
|
|
120
125
|
out.push(`${c.id} · ${KIND_LABEL[c.kind]} · ${c.project}${c.task ? ` [${c.task}]` : ""} · ${c.status}`);
|
|
@@ -125,10 +130,11 @@ export function cardDetail(c) {
|
|
|
125
130
|
out.push("");
|
|
126
131
|
c.options.forEach((o, i) => out.push(` ${i + 1}. ${o}${c.recommend === i + 1 ? " (recommended)" : ""}`));
|
|
127
132
|
}
|
|
133
|
+
const changed = c.changedAt ? ` (changed ${hhmm(c.changedAt)})` : "";
|
|
128
134
|
if (c.action)
|
|
129
|
-
out.push("", `Runs on yes: ${c.action}`);
|
|
135
|
+
out.push("", `Runs on yes${changed}: ${c.action}`);
|
|
130
136
|
if (c.preview)
|
|
131
|
-
out.push("", "
|
|
137
|
+
out.push("", `Preview${c.action ? "" : changed}${c.previewFile ? ` of ${c.previewFile}` : ""}:`, ...c.preview.split("\n").map((l) => ` ${l}`));
|
|
132
138
|
if (c.look)
|
|
133
139
|
out.push("", `Look at: ${c.look}`);
|
|
134
140
|
for (const o of c.open ?? [])
|
|
@@ -321,6 +327,9 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
|
|
|
321
327
|
const all = blocks.flatMap((b) => b.rows);
|
|
322
328
|
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
329
|
const wideW = v.wide ? 24 : 0;
|
|
330
|
+
// the machine a run is on gets a column of its own, so a long title cannot cut it off
|
|
331
|
+
const devW = Math.min(12, Math.max(0, ...all.map((row) => (row.run?.device ? textWidth(row.run.device) : 0))));
|
|
332
|
+
const devCol = devW ? devW + 2 : 0;
|
|
324
333
|
const out = [];
|
|
325
334
|
for (const { name, rows } of blocks) {
|
|
326
335
|
if (name === OTHER)
|
|
@@ -339,15 +348,16 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
|
|
|
339
348
|
const ended = ENDED.has(r.state);
|
|
340
349
|
const titlePaint = r.role === "lead" ? ["bold"] : ended ? ["dim"] : [];
|
|
341
350
|
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)` : "")
|
|
351
|
+
const label = r.title + (r.repeats ? ` ×${r.repeats + 1}` : "") + (r.prs?.length ? ` (${r.prs.length} PR)` : "");
|
|
343
352
|
const title = paint(v, pad(fit(label, room), room), ...titlePaint);
|
|
344
353
|
const age = paint(v, ago(r.lastActivity, now).padStart(4), "dim");
|
|
354
|
+
const dev = devW ? " " + paint(v, pad(fit(r.device ?? "", devW), devW), "cyan") : "";
|
|
345
355
|
const src = (r.projectSource && SOURCE_NOTE[r.projectSource]) || "";
|
|
346
356
|
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;
|
|
357
|
+
const restW = v.width - nameW - 2 - 4 - devCol - wideW - 2;
|
|
348
358
|
const whatPaint = r.error || r.state === "stalled" || r.state === "failed" || r.asks?.blocked ? ["red"] : r.asks ? ["yellow"] : ended ? ["dim"] : [];
|
|
349
359
|
const what = restW >= 8 ? " " + paint(v, fit(whatText(r), restW), ...whatPaint) : "";
|
|
350
|
-
out.push(`${lead}${mark} ${title} ${age}${wide}${what}`.trimEnd());
|
|
360
|
+
out.push(`${lead}${mark} ${title} ${age}${dev}${wide}${what}`.trimEnd());
|
|
351
361
|
}
|
|
352
362
|
}
|
|
353
363
|
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,12 @@ 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";
|
|
7
|
+
import { collectRuns } from "./runs.js";
|
|
6
8
|
import { pathMap } from "./sync.js";
|
|
7
9
|
import { CARD_KINDS, HUMAN, PR_CHANGES } from "./types.js";
|
|
8
|
-
export { UserError };
|
|
10
|
+
export { UserError, sessionTitle };
|
|
9
11
|
/** Current state, with other machines' paths made local. */
|
|
10
12
|
export function readState(events = readEvents()) {
|
|
11
13
|
return fold(events, pathMap());
|
|
@@ -18,9 +20,21 @@ function cleanList(xs) {
|
|
|
18
20
|
const out = (xs ?? []).map((x) => x.trim()).filter(Boolean);
|
|
19
21
|
return out.length ? out : undefined;
|
|
20
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* An attachment or a thing to open, as stored. A URL, and a path that is absolute on either
|
|
25
|
+
* system (`/Users/...`, `C:\...`, `\\server\share`), is kept as written: it may name a place on
|
|
26
|
+
* another machine, and resolving it here would root it on this one (`E:\Users\...` on Windows,
|
|
27
|
+
* `<cwd>/C:\...` on a Mac). Only a relative path is resolved.
|
|
28
|
+
*/
|
|
29
|
+
export function storedPath(x) {
|
|
30
|
+
if (/^[a-z]+:\/\//i.test(x))
|
|
31
|
+
return x;
|
|
32
|
+
if (path.posix.isAbsolute(x) || path.win32.isAbsolute(x))
|
|
33
|
+
return x;
|
|
34
|
+
return path.resolve(x);
|
|
35
|
+
}
|
|
21
36
|
function absPaths(xs) {
|
|
22
|
-
|
|
23
|
-
return cleanList(xs)?.map((x) => (/^[a-z]+:\/\//i.test(x) ? x : path.resolve(x)));
|
|
37
|
+
return cleanList(xs)?.map(storedPath);
|
|
24
38
|
}
|
|
25
39
|
export function addProject(name, paths) {
|
|
26
40
|
if (!/^[A-Za-z0-9._-]+$/.test(name))
|
|
@@ -40,14 +54,15 @@ const PREVIEW_MAX = 20_000;
|
|
|
40
54
|
function readPreview(file) {
|
|
41
55
|
if (!file?.trim())
|
|
42
56
|
return undefined;
|
|
57
|
+
const abs = path.resolve(file);
|
|
43
58
|
let text;
|
|
44
59
|
try {
|
|
45
|
-
text = fs.readFileSync(
|
|
60
|
+
text = fs.readFileSync(abs, "utf8");
|
|
46
61
|
}
|
|
47
62
|
catch {
|
|
48
63
|
throw new UserError(`cannot read the preview file ${file}`);
|
|
49
64
|
}
|
|
50
|
-
return text.length > PREVIEW_MAX ? text.slice(0, PREVIEW_MAX) + "\n…" : text;
|
|
65
|
+
return { preview: text.length > PREVIEW_MAX ? text.slice(0, PREVIEW_MAX) + "\n…" : text, previewFile: abs };
|
|
51
66
|
}
|
|
52
67
|
/** A date or timestamp as ISO text; a bare date counts as local midnight. */
|
|
53
68
|
export function parseWhen(s) {
|
|
@@ -57,13 +72,6 @@ export function parseWhen(s) {
|
|
|
57
72
|
throw new UserError(`"${s}" is not a date (use YYYY-MM-DD)`);
|
|
58
73
|
return d.toISOString();
|
|
59
74
|
}
|
|
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
75
|
function checkKind(kind) {
|
|
68
76
|
if (!CARD_KINDS.includes(kind))
|
|
69
77
|
throw new UserError(`kind must be one of ${CARD_KINDS.join(", ")}`);
|
|
@@ -77,7 +85,7 @@ export function openCard(input) {
|
|
|
77
85
|
const action = input.action?.trim() || undefined;
|
|
78
86
|
// an approval with no choices of its own is a yes or a no
|
|
79
87
|
const options = cleanList(input.options) ?? (action ? ["Do it", "Don't"] : undefined);
|
|
80
|
-
const
|
|
88
|
+
const pv = readPreview(input.preview);
|
|
81
89
|
if (input.recommend !== undefined) {
|
|
82
90
|
if (!options)
|
|
83
91
|
throw new UserError("--recommend needs --option");
|
|
@@ -110,7 +118,8 @@ export function openCard(input) {
|
|
|
110
118
|
session,
|
|
111
119
|
since,
|
|
112
120
|
action,
|
|
113
|
-
preview,
|
|
121
|
+
preview: pv?.preview,
|
|
122
|
+
previewFile: pv?.previewFile,
|
|
114
123
|
},
|
|
115
124
|
};
|
|
116
125
|
});
|
|
@@ -169,7 +178,8 @@ export function commentCard(id, text, by) {
|
|
|
169
178
|
appendEvent(() => ({ type: "card.comment", card: id, by, text: text.trim() }));
|
|
170
179
|
}
|
|
171
180
|
export function editCard(id, set) {
|
|
172
|
-
|
|
181
|
+
const card = requireCard(id);
|
|
182
|
+
id = card.id;
|
|
173
183
|
const clean = {};
|
|
174
184
|
if (set.kind !== undefined)
|
|
175
185
|
clean.kind = checkKind(set.kind);
|
|
@@ -190,6 +200,19 @@ export function editCard(id, set) {
|
|
|
190
200
|
clean.session = set.session.trim() || cleared;
|
|
191
201
|
if (set.since !== undefined)
|
|
192
202
|
clean.since = set.since.trim() ? parseWhen(set.since) : cleared;
|
|
203
|
+
const approval = set.action !== undefined || set.preview !== undefined || set.attach !== undefined;
|
|
204
|
+
if (approval && card.answers.length) {
|
|
205
|
+
throw new UserError(`card ${id} was already answered; close it and open a new one (a yes must not apply to text the human did not see)`);
|
|
206
|
+
}
|
|
207
|
+
if (set.action !== undefined)
|
|
208
|
+
clean.action = set.action.trim() || cleared;
|
|
209
|
+
if (set.preview !== undefined) {
|
|
210
|
+
const pv = readPreview(set.preview);
|
|
211
|
+
clean.preview = pv?.preview ?? cleared;
|
|
212
|
+
clean.previewFile = pv?.previewFile ?? cleared;
|
|
213
|
+
}
|
|
214
|
+
if (set.attach !== undefined)
|
|
215
|
+
clean.attach = absPaths(set.attach) ?? cleared;
|
|
193
216
|
if (!Object.keys(clean).length)
|
|
194
217
|
throw new UserError("nothing to change");
|
|
195
218
|
appendEvent(() => ({ type: "card.edit", card: id, set: clean }));
|
|
@@ -224,6 +247,34 @@ export function endRun(id, status, note) {
|
|
|
224
247
|
const run = requireRun(id);
|
|
225
248
|
appendEvent(() => ({ type: "run.end", run, status, note }));
|
|
226
249
|
}
|
|
250
|
+
/** The id of a seen or declared run from the start of it (6+ characters), or the exact id. */
|
|
251
|
+
function resolveRunId(id, state) {
|
|
252
|
+
const exact = (xs) => (xs.includes(id) ? [id] : xs.filter((x) => x.startsWith(id)));
|
|
253
|
+
if (id.length < 6 && !state.runs.has(id))
|
|
254
|
+
throw new UserError("give at least 6 characters of the run id");
|
|
255
|
+
const seen = collectRuns(state, { sinceHours: 24 * 7, includeEnded: true, keepRepeats: true }).map((r) => r.id);
|
|
256
|
+
const found = [...new Set(exact([...seen, ...state.runs.keys(), ...state.hidden.keys()]))];
|
|
257
|
+
if (found.length === 1)
|
|
258
|
+
return found[0];
|
|
259
|
+
if (!found.length)
|
|
260
|
+
throw new UserError(`no run "${id}" (parawatch runs --wide shows ids)`);
|
|
261
|
+
throw new UserError(`"${id}" matches ${found.length} runs; give more of the id`);
|
|
262
|
+
}
|
|
263
|
+
/** Hide a run from the lists until it does something new. Works for runs other machines shared. */
|
|
264
|
+
export function hideRun(id) {
|
|
265
|
+
const run = resolveRunId(id, readState());
|
|
266
|
+
appendEvent(() => ({ type: "run.hide", run }));
|
|
267
|
+
return run;
|
|
268
|
+
}
|
|
269
|
+
export function unhideRun(id) {
|
|
270
|
+
const state = readState();
|
|
271
|
+
const hidden = [...state.hidden.keys()].filter((k) => k === id || k.startsWith(id));
|
|
272
|
+
const run = hidden.length === 1 ? hidden[0] : hidden.length ? hidden.find((k) => k === id) : resolveRunId(id, state);
|
|
273
|
+
if (!run)
|
|
274
|
+
throw new UserError(`"${id}" matches ${hidden.length} hidden runs; give more of the id`);
|
|
275
|
+
appendEvent(() => ({ type: "run.unhide", run }));
|
|
276
|
+
return run;
|
|
277
|
+
}
|
|
227
278
|
/**
|
|
228
279
|
* Say which project and task a session works on. A claim without a project keeps the session's
|
|
229
280
|
* earlier claim and only changes what is given (for `--done`). A claim of a new task clears `done`.
|
|
@@ -277,10 +328,32 @@ export function prEvent(pr, change, opts = {}) {
|
|
|
277
328
|
export function sameSession(a, b) {
|
|
278
329
|
return !!a && !!b && (a.startsWith(b) || b.startsWith(a));
|
|
279
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* Whether a session has ended: it said so with `claim --done`, or the human archived it in the
|
|
333
|
+
* Claude desktop app. Cards and pull requests it owned then belong to its project, so an answer
|
|
334
|
+
* still reaches a session waiting there. The desktop app is read only when asked about a session
|
|
335
|
+
* that has not claimed done.
|
|
336
|
+
*/
|
|
337
|
+
export function endedSessions(state, isArchived) {
|
|
338
|
+
let archived = isArchived;
|
|
339
|
+
return (session) => {
|
|
340
|
+
const claim = state.claims.find((c) => sameSession(c.session, session));
|
|
341
|
+
if (claim?.done)
|
|
342
|
+
return true;
|
|
343
|
+
archived ??= archivedSessions();
|
|
344
|
+
return archived(session);
|
|
345
|
+
};
|
|
346
|
+
}
|
|
280
347
|
/** 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
|
-
|
|
348
|
+
function inScope(owner, project, f, ended) {
|
|
349
|
+
if (f.session) {
|
|
350
|
+
if (owner && sameSession(owner, f.session))
|
|
351
|
+
return true;
|
|
352
|
+
// what a session that has ended owned is the project's now
|
|
353
|
+
if (owner && !ended(owner))
|
|
354
|
+
return false;
|
|
355
|
+
return !!f.project && project === f.project;
|
|
356
|
+
}
|
|
284
357
|
return !f.project || project === f.project;
|
|
285
358
|
}
|
|
286
359
|
/** Two machines that both read GitHub report the same change; the later report is an echo. */
|
|
@@ -295,6 +368,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
295
368
|
const out = [];
|
|
296
369
|
const reported = new Map();
|
|
297
370
|
const answerNo = new Map();
|
|
371
|
+
const ended = filter.ended ?? endedSessions(state);
|
|
298
372
|
for (const ev of events) {
|
|
299
373
|
if (ev.type === "pr.change") {
|
|
300
374
|
const k = `${ev.pr.key}|${ev.change}|${ev.text ?? ""}`;
|
|
@@ -306,7 +380,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
306
380
|
if (!isAfter(ev, pos))
|
|
307
381
|
continue;
|
|
308
382
|
const link = state.prLinks.get(ev.pr.key);
|
|
309
|
-
if (!link || filter.card || !inScope(link.session, link.project, filter))
|
|
383
|
+
if (!link || filter.card || !inScope(link.session, link.project, filter, ended))
|
|
310
384
|
continue;
|
|
311
385
|
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
386
|
continue;
|
|
@@ -326,7 +400,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
326
400
|
const card = state.cards.get(ev.card);
|
|
327
401
|
if (!card || card.status === "closed")
|
|
328
402
|
continue;
|
|
329
|
-
if (!inScope(card.session, card.project, filter))
|
|
403
|
+
if (!inScope(card.session, card.project, filter, ended))
|
|
330
404
|
continue;
|
|
331
405
|
if (filter.card && card.id !== filter.card)
|
|
332
406
|
continue;
|
|
@@ -442,14 +516,20 @@ export function listeners(now = Date.now()) {
|
|
|
442
516
|
}
|
|
443
517
|
return out;
|
|
444
518
|
}
|
|
445
|
-
/**
|
|
446
|
-
|
|
519
|
+
/**
|
|
520
|
+
* The waits an answer to this card would wake right now. A card whose session has ended reaches
|
|
521
|
+
* the session waits in its project, as an unowned card does. A snapshot's card says so itself
|
|
522
|
+
* (`ownerEnded`); otherwise `ended` tells.
|
|
523
|
+
*/
|
|
524
|
+
export function listenersFor(card, ls, ended) {
|
|
525
|
+
let gone = card.ownerEnded;
|
|
526
|
+
const ownerGone = () => (gone ??= !!card.session && (ended ?? endedSessions(readState()))(card.session));
|
|
447
527
|
return ls.filter((l) => l.card
|
|
448
528
|
? l.card === card.id
|
|
449
529
|
: l.scope === "session"
|
|
450
|
-
? card.session
|
|
451
|
-
?
|
|
452
|
-
: !!l.project && l.project === card.project
|
|
530
|
+
? card.session && sameSession(l.session, card.session)
|
|
531
|
+
? true
|
|
532
|
+
: (!card.session || ownerGone()) && !!l.project && l.project === card.project
|
|
453
533
|
: !l.project || l.project === card.project);
|
|
454
534
|
}
|
|
455
535
|
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
|
|
@@ -337,6 +363,27 @@ export function otherRuns(state, now = Date.now()) {
|
|
|
337
363
|
}
|
|
338
364
|
return out;
|
|
339
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* Leave out runs the human hid. A run comes back once it has done something after it was hidden.
|
|
368
|
+
* Children of a hidden session stay hidden by the same rule, measured from the parent's hide time.
|
|
369
|
+
*/
|
|
370
|
+
function withoutHidden(runs, state) {
|
|
371
|
+
if (!state.hidden.size)
|
|
372
|
+
return runs;
|
|
373
|
+
const byId = new Map(runs.map((r) => [r.id, r]));
|
|
374
|
+
const hiddenAt = (r, seen = new Set()) => {
|
|
375
|
+
seen.add(r.id);
|
|
376
|
+
const own = state.hidden.get(r.id);
|
|
377
|
+
const at = own !== undefined ? Date.parse(own) : r.parent && !seen.has(r.parent) && byId.has(r.parent) ? hiddenAt(byId.get(r.parent), seen) : undefined;
|
|
378
|
+
return at !== undefined && Number.isFinite(at) ? at : undefined;
|
|
379
|
+
};
|
|
380
|
+
return runs.filter((r) => {
|
|
381
|
+
const at = hiddenAt(r);
|
|
382
|
+
if (at === undefined)
|
|
383
|
+
return true;
|
|
384
|
+
return r.lastActivity ? Date.parse(r.lastActivity) > at : false;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
340
387
|
const ENDED = new Set(["done", "idle", "failed"]);
|
|
341
388
|
const RECENT_END_MS = 30 * 60_000;
|
|
342
389
|
const OLD_STALL_MS = 3 * 3_600_000;
|
|
@@ -344,7 +391,7 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
344
391
|
const sinceMs = now - q.sinceHours * 3_600_000;
|
|
345
392
|
// a log file can be touched (title records, snapshots) long after its last real activity
|
|
346
393
|
const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
|
|
347
|
-
let runs = attribute(scanAll({ sinceMs, now }, q.runtimes), state).filter(recent);
|
|
394
|
+
let runs = endArchived(attribute(scanAll({ sinceMs, now }, q.runtimes), state), archivedSessions()).filter(recent);
|
|
348
395
|
// a narrower reading (one runtime) would hide the rest from the other machines
|
|
349
396
|
if (!q.runtimes)
|
|
350
397
|
shareRuns(runs, now);
|
|
@@ -366,13 +413,16 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
366
413
|
return true; // still waiting for the human to read it
|
|
367
414
|
if (live.has(r.id))
|
|
368
415
|
return true;
|
|
369
|
-
|
|
416
|
+
// a lead idle between turns stays; one that really ended (claimed done, archived) follows the normal rule
|
|
417
|
+
if (r.role === "lead" && !r.done)
|
|
370
418
|
return true;
|
|
371
419
|
return r.lastActivity ? now - Date.parse(r.lastActivity) < RECENT_END_MS : false;
|
|
372
420
|
});
|
|
373
421
|
}
|
|
374
422
|
if (!q.keepRepeats)
|
|
375
423
|
runs = foldRepeats(runs);
|
|
424
|
+
if (!q.includeEnded)
|
|
425
|
+
runs = withoutHidden(runs, state);
|
|
376
426
|
return runs.sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
|
|
377
427
|
}
|
|
378
428
|
//# sourceMappingURL=runs.js.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
export const CLAUDE_SITE = "https://claude.ai/";
|
|
2
|
+
export const CODEX_SITE = "https://developers.openai.com/codex";
|
|
3
|
+
/** The fixed sites, with the name the tooltip uses. */
|
|
4
|
+
export const SERVICE_NAMES = {
|
|
5
|
+
[CLAUDE_SITE]: "Claude Code",
|
|
6
|
+
[CODEX_SITE]: "Codex",
|
|
7
|
+
};
|
|
8
|
+
/** The site for a run's runtime; declared runs and unknown runtimes have none. */
|
|
9
|
+
export function siteForRun(run) {
|
|
10
|
+
if (run.runtime === "claude-code")
|
|
11
|
+
return CLAUDE_SITE;
|
|
12
|
+
if (run.runtime === "codex")
|
|
13
|
+
return CODEX_SITE;
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
/** The site for a pull request, from its URL (`https://<host>/...` becomes `https://<host>/`). */
|
|
17
|
+
export function siteForPr(pr) {
|
|
18
|
+
try {
|
|
19
|
+
const u = new URL(pr.url);
|
|
20
|
+
return u.protocol === "https:" && u.hostname ? `https://${u.host}/` : undefined;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether `site` may be fetched: a plain https URL with no credentials, query or fragment, that is
|
|
28
|
+
* one of the fixed sites or the root of a host in `prHosts`.
|
|
29
|
+
*/
|
|
30
|
+
export function allowedSite(site, prHosts) {
|
|
31
|
+
if (typeof site !== "string")
|
|
32
|
+
return false;
|
|
33
|
+
let u;
|
|
34
|
+
try {
|
|
35
|
+
u = new URL(site);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash)
|
|
41
|
+
return false;
|
|
42
|
+
if (u.href !== site)
|
|
43
|
+
return false;
|
|
44
|
+
if (site in SERVICE_NAMES)
|
|
45
|
+
return true;
|
|
46
|
+
if (u.pathname !== "/")
|
|
47
|
+
return false;
|
|
48
|
+
for (const h of prHosts)
|
|
49
|
+
if (h.toLowerCase() === u.host)
|
|
50
|
+
return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
/** The runtime's product name, for tooltips. */
|
|
54
|
+
export function serviceName(run) {
|
|
55
|
+
const site = siteForRun(run);
|
|
56
|
+
return site ? SERVICE_NAMES[site] : undefined;
|
|
57
|
+
}
|
|
58
|
+
/** How long an icon is kept before it is fetched again, and how long a failure is remembered. */
|
|
59
|
+
export const ICON_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
60
|
+
export const ICON_FAIL_TTL_MS = 24 * 60 * 60 * 1000;
|
|
61
|
+
/** `fresh`: use the stored icon; `failed`: do not try again yet; `stale`: fetch it. */
|
|
62
|
+
export function iconCacheState(e, now) {
|
|
63
|
+
const fetched = e?.fetchedAt ? Date.parse(e.fetchedAt) : NaN;
|
|
64
|
+
const failed = e?.failedAt ? Date.parse(e.failedAt) : NaN;
|
|
65
|
+
if (Number.isFinite(failed) && now - failed < ICON_FAIL_TTL_MS && !(Number.isFinite(fetched) && fetched > failed))
|
|
66
|
+
return "failed";
|
|
67
|
+
if (Number.isFinite(fetched) && now - fetched < ICON_TTL_MS)
|
|
68
|
+
return "fresh";
|
|
69
|
+
return "stale";
|
|
70
|
+
}
|
|
71
|
+
function attrs(tag) {
|
|
72
|
+
const out = {};
|
|
73
|
+
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
74
|
+
for (const m of tag.replace(/^<link\b/i, "").matchAll(re))
|
|
75
|
+
out[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** The icon links a page declares (`rel` icon, shortcut icon, apple-touch-icon), as absolute https URLs. */
|
|
79
|
+
export function parseIconLinks(html, base) {
|
|
80
|
+
const found = [];
|
|
81
|
+
for (const m of html.matchAll(/<link\b[^>]*>/gi)) {
|
|
82
|
+
const a = attrs(m[0]);
|
|
83
|
+
const rel = (a.rel ?? "").toLowerCase().split(/\s+/);
|
|
84
|
+
if (!rel.includes("icon") && !rel.includes("apple-touch-icon"))
|
|
85
|
+
continue;
|
|
86
|
+
if (!a.href)
|
|
87
|
+
continue;
|
|
88
|
+
let url;
|
|
89
|
+
try {
|
|
90
|
+
url = new URL(a.href, base);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (url.protocol !== "https:")
|
|
96
|
+
continue;
|
|
97
|
+
const svg = (a.type ?? "").toLowerCase().includes("svg") || /\.svg$/i.test(url.pathname);
|
|
98
|
+
let size;
|
|
99
|
+
for (const s of (a.sizes ?? "").toLowerCase().split(/\s+/)) {
|
|
100
|
+
const wh = /^(\d+)x(\d+)$/.exec(s);
|
|
101
|
+
if (wh)
|
|
102
|
+
size = Math.max(size ?? 0, Number(wh[1]), Number(wh[2]));
|
|
103
|
+
}
|
|
104
|
+
found.push({ url: url.href, svg, size });
|
|
105
|
+
}
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The URLs to try, best first: an SVG, then the raster icon closest to 32px or larger, then the
|
|
110
|
+
* site's `/favicon.ico`.
|
|
111
|
+
*/
|
|
112
|
+
export function rankIcons(links, base) {
|
|
113
|
+
const score = (l) => {
|
|
114
|
+
if (l.svg)
|
|
115
|
+
return -1;
|
|
116
|
+
const s = l.size ?? 32;
|
|
117
|
+
return s >= 32 ? s - 32 : 1000 + (32 - s);
|
|
118
|
+
};
|
|
119
|
+
const urls = links
|
|
120
|
+
.map((l, i) => ({ l, i }))
|
|
121
|
+
.sort((a, b) => score(a.l) - score(b.l) || a.i - b.i)
|
|
122
|
+
.map((x) => x.l.url);
|
|
123
|
+
try {
|
|
124
|
+
urls.push(new URL("/favicon.ico", base).href);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// not a URL; nothing to fall back to
|
|
128
|
+
}
|
|
129
|
+
return [...new Set(urls)];
|
|
130
|
+
}
|
|
131
|
+
/** The file name an icon is stored under: the site without its scheme, safe for any file system. */
|
|
132
|
+
export function siteFileKey(site) {
|
|
133
|
+
return site.replace(/^https:\/\//, "").replace(/\/+$/, "").replace(/[^a-zA-Z0-9.-]+/g, "_").toLowerCase();
|
|
134
|
+
}
|
|
135
|
+
const EXTENSIONS = {
|
|
136
|
+
"image/svg+xml": "svg",
|
|
137
|
+
"image/png": "png",
|
|
138
|
+
"image/x-icon": "ico",
|
|
139
|
+
"image/vnd.microsoft.icon": "ico",
|
|
140
|
+
"image/jpeg": "jpg",
|
|
141
|
+
"image/gif": "gif",
|
|
142
|
+
"image/webp": "webp",
|
|
143
|
+
};
|
|
144
|
+
/** The image types an icon may have; anything else is not shown. */
|
|
145
|
+
export function iconExtension(mime) {
|
|
146
|
+
return EXTENSIONS[mime.split(";")[0].trim().toLowerCase()];
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=sites.js.map
|
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,9 +1,16 @@
|
|
|
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
|
}
|
|
4
11
|
/** Rebuild current state from the event log. `map` makes other machines' paths local. */
|
|
5
12
|
export function fold(events, map) {
|
|
6
|
-
const s = { projects: new Map(), cards: new Map(), runs: new Map(), claims: [], prLinks: new Map(), lastSeq: 0 };
|
|
13
|
+
const s = { projects: new Map(), cards: new Map(), runs: new Map(), claims: [], hidden: new Map(), prLinks: new Map(), lastSeq: 0 };
|
|
7
14
|
for (const ev of events) {
|
|
8
15
|
s.lastSeq = Math.max(s.lastSeq, ev.seq);
|
|
9
16
|
switch (ev.type) {
|
|
@@ -19,6 +26,8 @@ export function fold(events, map) {
|
|
|
19
26
|
card.dev = ev.dev;
|
|
20
27
|
card.attach = mapAll(card.attach, map, ev.dev);
|
|
21
28
|
card.open = mapAll(card.open, map, ev.dev);
|
|
29
|
+
if (card.previewFile)
|
|
30
|
+
card.previewFile = mapAll([card.previewFile], map, ev.dev)?.[0];
|
|
22
31
|
s.cards.set(ev.card.id, card);
|
|
23
32
|
break;
|
|
24
33
|
}
|
|
@@ -51,9 +60,15 @@ export function fold(events, map) {
|
|
|
51
60
|
for (const [k, v] of Object.entries(ev.set)) {
|
|
52
61
|
if (v === null || v === undefined)
|
|
53
62
|
delete target[k];
|
|
63
|
+
else if (k === "attach")
|
|
64
|
+
c.attach = mapAll(ev.set.attach, map, ev.dev);
|
|
65
|
+
else if (k === "previewFile")
|
|
66
|
+
c.previewFile = mapAll([ev.set.previewFile], map, ev.dev)?.[0];
|
|
54
67
|
else
|
|
55
68
|
target[k] = v;
|
|
56
69
|
}
|
|
70
|
+
if ("action" in ev.set || "preview" in ev.set)
|
|
71
|
+
c.changedAt = ev.ts;
|
|
57
72
|
break;
|
|
58
73
|
}
|
|
59
74
|
case "card.close": {
|
|
@@ -98,6 +113,12 @@ export function fold(events, map) {
|
|
|
98
113
|
s.claims = s.claims.filter((c) => c.session !== ev.claim.session);
|
|
99
114
|
s.claims.push({ ...ev.claim, at: ev.ts });
|
|
100
115
|
break;
|
|
116
|
+
case "run.hide":
|
|
117
|
+
s.hidden.set(ev.run, ev.ts);
|
|
118
|
+
break;
|
|
119
|
+
case "run.unhide":
|
|
120
|
+
s.hidden.delete(ev.run);
|
|
121
|
+
break;
|
|
101
122
|
case "pr.link":
|
|
102
123
|
s.prLinks.set(ev.link.pr.key, { ...ev.link, at: ev.ts });
|
|
103
124
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parawatch",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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",
|
|
@@ -56,6 +56,7 @@ A good card:
|
|
|
56
56
|
- **Add `-t <task>`** when the project runs several tasks at once, so the human can tell them apart.
|
|
57
57
|
- **`block` is red and counts as needing them now.** Use it only when your work stops until they act. Errands for later (a bill, a registration, a policy check, a measurement whenever the machine is free) are `todo`; they wait folded in the panel and do not pop up.
|
|
58
58
|
- **Moving items from a board or a note:** pass `--since <YYYY-MM-DD>` with the date the item first came up, so the card does not look new. `parawatch edit <card>` fixes a card's kind, wording or date later.
|
|
59
|
+
- **`--action` is the exact command, one line if possible.** Steps and reasons go in the body, and `--preview` is the file with the text it sends (the human can open that file from the card). Change `--action`, `--preview` or `--attach` with `parawatch edit` while the card is unanswered; once answered, close it and open a new one.
|
|
59
60
|
- **An approval shows exactly what runs.** With `--action` the human sees the command and the preview and answers Do it or Don't, without opening your session. Run exactly that on yes; say in the close note that you ran it.
|
|
60
61
|
- **Never leave a question only in your reply.** If your turn ends needing the human, the question goes on a card. Parawatch flags questions it finds at the end of a turn as "asked in chat, no card", but that is a guess and easy to miss.
|
|
61
62
|
- Do not open a card for something you can decide yourself, or that the project's own rules already settle.
|
|
@@ -135,6 +136,7 @@ When `parawatch sync` shows other machines, the human may answer from any of the
|
|
|
135
136
|
```
|
|
136
137
|
parawatch status # everything: what needs the human, what waits on others, what is running
|
|
137
138
|
parawatch runs -p <project> --wide # agents with ids
|
|
139
|
+
parawatch run hide <id> # the human asks to hide a run; it returns when it does something new
|
|
138
140
|
parawatch pr list # open pull requests and what each waits on
|
|
139
141
|
parawatch digest # what each session did today
|
|
140
142
|
parawatch show <card> # one card in full
|