parawatch 0.3.3 → 0.3.5

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 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
  ```
@@ -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
- export function applyStall(run, pendingTimeoutMs, now) {
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 or when it came up (an empty value clears)")
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, { kind: o.kind, title: o.title, body: o.body, task: o.task, from: o.from, session: o.session, since: o.since });
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)")
@@ -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("", "Preview:", ...c.preview.split("\n").map((l) => ` ${l}`));
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 ?? [])
package/dist/core/ops.js CHANGED
@@ -4,6 +4,7 @@ import { UserError } from "./errors.js";
4
4
  import { advance, appendEvent, deviceName, eventsDir, homeDir, isAfter, readCursor, readEvents, sharedDir, writeCursor } from "./store.js";
5
5
  import { archivedSessions } from "./desktop.js";
6
6
  import { findId, fold, nextId, sessionTitle } from "./state.js";
7
+ import { collectRuns } from "./runs.js";
7
8
  import { pathMap } from "./sync.js";
8
9
  import { CARD_KINDS, HUMAN, PR_CHANGES } from "./types.js";
9
10
  export { UserError, sessionTitle };
@@ -53,14 +54,15 @@ const PREVIEW_MAX = 20_000;
53
54
  function readPreview(file) {
54
55
  if (!file?.trim())
55
56
  return undefined;
57
+ const abs = path.resolve(file);
56
58
  let text;
57
59
  try {
58
- text = fs.readFileSync(path.resolve(file), "utf8");
60
+ text = fs.readFileSync(abs, "utf8");
59
61
  }
60
62
  catch {
61
63
  throw new UserError(`cannot read the preview file ${file}`);
62
64
  }
63
- 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 };
64
66
  }
65
67
  /** A date or timestamp as ISO text; a bare date counts as local midnight. */
66
68
  export function parseWhen(s) {
@@ -83,7 +85,7 @@ export function openCard(input) {
83
85
  const action = input.action?.trim() || undefined;
84
86
  // an approval with no choices of its own is a yes or a no
85
87
  const options = cleanList(input.options) ?? (action ? ["Do it", "Don't"] : undefined);
86
- const preview = readPreview(input.preview);
88
+ const pv = readPreview(input.preview);
87
89
  if (input.recommend !== undefined) {
88
90
  if (!options)
89
91
  throw new UserError("--recommend needs --option");
@@ -116,7 +118,8 @@ export function openCard(input) {
116
118
  session,
117
119
  since,
118
120
  action,
119
- preview,
121
+ preview: pv?.preview,
122
+ previewFile: pv?.previewFile,
120
123
  },
121
124
  };
122
125
  });
@@ -175,7 +178,8 @@ export function commentCard(id, text, by) {
175
178
  appendEvent(() => ({ type: "card.comment", card: id, by, text: text.trim() }));
176
179
  }
177
180
  export function editCard(id, set) {
178
- id = requireCard(id).id;
181
+ const card = requireCard(id);
182
+ id = card.id;
179
183
  const clean = {};
180
184
  if (set.kind !== undefined)
181
185
  clean.kind = checkKind(set.kind);
@@ -196,6 +200,19 @@ export function editCard(id, set) {
196
200
  clean.session = set.session.trim() || cleared;
197
201
  if (set.since !== undefined)
198
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;
199
216
  if (!Object.keys(clean).length)
200
217
  throw new UserError("nothing to change");
201
218
  appendEvent(() => ({ type: "card.edit", card: id, set: clean }));
@@ -230,6 +247,34 @@ export function endRun(id, status, note) {
230
247
  const run = requireRun(id);
231
248
  appendEvent(() => ({ type: "run.end", run, status, note }));
232
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
+ }
233
278
  /**
234
279
  * Say which project and task a session works on. A claim without a project keeps the session's
235
280
  * earlier claim and only changes what is given (for `--done`). A claim of a new task clears `done`.
package/dist/core/runs.js CHANGED
@@ -363,6 +363,27 @@ export function otherRuns(state, now = Date.now()) {
363
363
  }
364
364
  return out;
365
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
+ }
366
387
  const ENDED = new Set(["done", "idle", "failed"]);
367
388
  const RECENT_END_MS = 30 * 60_000;
368
389
  const OLD_STALL_MS = 3 * 3_600_000;
@@ -392,13 +413,16 @@ export function collectRuns(state, q, now = Date.now()) {
392
413
  return true; // still waiting for the human to read it
393
414
  if (live.has(r.id))
394
415
  return true;
395
- if (r.role === "lead")
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)
396
418
  return true;
397
419
  return r.lastActivity ? now - Date.parse(r.lastActivity) < RECENT_END_MS : false;
398
420
  });
399
421
  }
400
422
  if (!q.keepRepeats)
401
423
  runs = foldRepeats(runs);
424
+ if (!q.includeEnded)
425
+ runs = withoutHidden(runs, state);
402
426
  return runs.sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
403
427
  }
404
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
@@ -10,7 +10,7 @@ function mapAll(xs, map, from) {
10
10
  }
11
11
  /** Rebuild current state from the event log. `map` makes other machines' paths local. */
12
12
  export function fold(events, map) {
13
- 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 };
14
14
  for (const ev of events) {
15
15
  s.lastSeq = Math.max(s.lastSeq, ev.seq);
16
16
  switch (ev.type) {
@@ -26,6 +26,8 @@ export function fold(events, map) {
26
26
  card.dev = ev.dev;
27
27
  card.attach = mapAll(card.attach, map, ev.dev);
28
28
  card.open = mapAll(card.open, map, ev.dev);
29
+ if (card.previewFile)
30
+ card.previewFile = mapAll([card.previewFile], map, ev.dev)?.[0];
29
31
  s.cards.set(ev.card.id, card);
30
32
  break;
31
33
  }
@@ -58,9 +60,15 @@ export function fold(events, map) {
58
60
  for (const [k, v] of Object.entries(ev.set)) {
59
61
  if (v === null || v === undefined)
60
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];
61
67
  else
62
68
  target[k] = v;
63
69
  }
70
+ if ("action" in ev.set || "preview" in ev.set)
71
+ c.changedAt = ev.ts;
64
72
  break;
65
73
  }
66
74
  case "card.close": {
@@ -105,6 +113,12 @@ export function fold(events, map) {
105
113
  s.claims = s.claims.filter((c) => c.session !== ev.claim.session);
106
114
  s.claims.push({ ...ev.claim, at: ev.ts });
107
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;
108
122
  case "pr.link":
109
123
  s.prLinks.set(ev.link.pr.key, { ...ev.link, at: ev.ts });
110
124
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parawatch",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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