parawatch 0.3.9 → 0.3.10

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.
@@ -50,6 +50,12 @@ function promptText(rec) {
50
50
  return undefined;
51
51
  return text;
52
52
  }
53
+ /** A message the log marks as typed or sent by the human, whatever its text looks like. */
54
+ function fromHuman(rec) {
55
+ if (rec.type !== "user" || rec.isMeta === true || !isObj(rec.origin) || rec.origin.kind !== "human")
56
+ return false;
57
+ return !blocks(rec).some((b) => b.type === "tool_result");
58
+ }
53
59
  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;
54
60
  const JOB_STARTED_NO_PATH = /(?:running in background with ID|moved to the background \(ID): ([\w-]+)/;
55
61
  const NOTICE = /<task-notification>([\s\S]*?)<\/task-notification>/g;
@@ -114,9 +120,10 @@ function pendingJobs(recs) {
114
120
  }
115
121
  const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
116
122
  const DIR_IN_COMMAND = /(?:^|[;&|]\s*|\s)(?:cd|git\s+-C)\s+("[^"]+"|'[^']+'|[^\s;&|]+)/g;
117
- const PR_URL = /https?:\/\/[^\s"'<>()]+\/pull\/\d+/g;
118
123
  /** The desktop app's end-of-turn tool: a recap, maybe a question for the human. */
119
124
  const WRAP_UP = /(^|__)wrap_up$/;
125
+ /** The tool a subagent calls to hand its report back to its caller; its result is the subagent's end. */
126
+ const HANDBACK = /(^|__)SubagentHandback$/;
120
127
  function resultText(b) {
121
128
  const c = b.content;
122
129
  if (typeof c === "string")
@@ -159,19 +166,46 @@ export function scanWork(recs) {
159
166
  const cmd = str(inp.command) ?? "";
160
167
  for (const m of cmd.matchAll(DIR_IN_COMMAND))
161
168
  add(unquote(m[1]));
162
- if (/\bgh\s+pr\s+create\b/.test(cmd) && b.id)
169
+ if (runsGhPrCreate(cmd) && b.id)
163
170
  prCalls.add(b.id);
164
171
  }
165
172
  }
166
173
  else if (b.type === "tool_result" && b.tool_use_id && prCalls.has(b.tool_use_id)) {
167
- for (const m of resultText(b).matchAll(PR_URL))
168
- if (!prs.includes(m[0]))
169
- prs.push(m[0]);
174
+ const url = createdPrUrl(resultText(b));
175
+ if (url && !prs.includes(url))
176
+ prs.push(url);
170
177
  }
171
178
  }
172
179
  }
173
180
  return { touched: touched.slice(-50), createdPrs: prs };
174
181
  }
182
+ /** Shell words that may come before the command itself. */
183
+ const PREFIX_WORD = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S*|env|command|time|noglob|exec|then|do|else|!)$/;
184
+ /**
185
+ * Whether a shell command runs `gh pr create`, as opposed to mentioning the words: a search for
186
+ * them, a quoted message, a heredoc. Each command of a list or pipeline is looked at on its own.
187
+ */
188
+ export function runsGhPrCreate(cmd) {
189
+ if (!/\bpr\s+create\b/.test(cmd))
190
+ return false;
191
+ const bare = cmd
192
+ .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, "") // heredoc bodies
193
+ .replace(/'[^']*'/g, "''")
194
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""');
195
+ for (const seg of bare.split(/&&|\|\||[;|\n&]|\$\(|`|[()]/)) {
196
+ const words = seg.trim().split(/\s+/).filter(Boolean);
197
+ while (words.length && PREFIX_WORD.test(words[0]))
198
+ words.shift();
199
+ if (/(^|[\\/])gh(\.exe)?$/.test(words[0] ?? "") && words[1] === "pr" && words[2] === "create")
200
+ return true;
201
+ }
202
+ return false;
203
+ }
204
+ /** The URL `gh pr create` prints when it opens a PR: a line of its own, the last one. */
205
+ export function createdPrUrl(output) {
206
+ const lines = output.split(/\r?\n/).map((l) => l.trim()).filter((l) => /^https?:\/\/\S+\/pull\/\d+$/.test(l));
207
+ return lines[lines.length - 1];
208
+ }
175
209
  /** The last line of an agent's closing message, when it is a question. */
176
210
  function trailingQuestion(text) {
177
211
  const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
@@ -201,6 +235,10 @@ export function readClaudeTail(recs, kind) {
201
235
  out.lastPromptAt = ts ?? out.lastPromptAt;
202
236
  wrapUp = undefined; // a new turn began
203
237
  }
238
+ else if (fromHuman(r)) {
239
+ // the human wrote (a pasted block, a command's output): whatever the last wrap-up asked is answered
240
+ wrapUp = undefined;
241
+ }
204
242
  if (r.type === "assistant") {
205
243
  const w = blocks(r).find((b) => b.type === "tool_use" && WRAP_UP.test(String(b.name)));
206
244
  if (w)
@@ -215,6 +253,7 @@ export function readClaudeTail(recs, kind) {
215
253
  const tool = [...bs].reverse().find((b) => b.type === "tool_use");
216
254
  if (tool) {
217
255
  out.state = "working";
256
+ out.pendingTool = true;
218
257
  out.action = toolSummary(tool);
219
258
  const t = tool.input?.timeout;
220
259
  if (typeof t === "number")
@@ -262,9 +301,28 @@ export function readClaudeTail(recs, kind) {
262
301
  out.state = "working";
263
302
  const result = bs.find((b) => b.type === "tool_result");
264
303
  out.action = result ? "thinking" : "reading a new message";
304
+ if (kind === "subagent" && result && result.is_error !== true && HANDBACK.test(toolName(recs, result.tool_use_id))) {
305
+ // the report went back to the caller: nothing more comes from this subagent
306
+ out.state = "done";
307
+ out.action = "handed its report back";
308
+ }
265
309
  }
266
310
  return out;
267
311
  }
312
+ /** The name of the tool call a result answers, looked up in the same records. */
313
+ function toolName(recs, id) {
314
+ if (!id)
315
+ return "";
316
+ for (let i = recs.length - 1; i >= 0; i--) {
317
+ const r = recs[i];
318
+ if (!isObj(r) || r.type !== "assistant")
319
+ continue;
320
+ const b = blocks(r).find((x) => x.type === "tool_use" && x.id === id);
321
+ if (b)
322
+ return String(b.name ?? "");
323
+ }
324
+ return "";
325
+ }
268
326
  function firstPrompt(file) {
269
327
  const out = {};
270
328
  for (const r of readHeadLines(file, HEAD_BYTES)) {
@@ -392,8 +450,12 @@ function readSession(file, now) {
392
450
  action: tail.action,
393
451
  prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
394
452
  activity: logActivity(file, now),
453
+ pendingTool: tail.pendingTool,
395
454
  };
396
455
  applyStall(run, tail.pendingTimeoutMs, now, newestMtime(tail.pendingOutputs));
456
+ // in the log a permission prompt and a long command look the same; the hook can tell them apart
457
+ if (run.state === "stalled" && tail.pendingTool)
458
+ run.action = `${run.action ?? "a tool call"} · or a permission prompt is open`;
397
459
  return run;
398
460
  }
399
461
  catch (err) {
@@ -424,6 +486,12 @@ function readSubagent(file, now) {
424
486
  prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
425
487
  activity: logActivity(file, now),
426
488
  };
489
+ if (meta.finished === true && run.state !== "done" && run.state !== "failed" && metaIsCurrent(file)) {
490
+ // Claude Code marks a subagent finished in its meta file; the log tail can still look mid-turn
491
+ run.state = "done";
492
+ if (run.action === "thinking" || run.action === "reading a new message")
493
+ run.action = "finished";
494
+ }
427
495
  applyStall(run, tail.pendingTimeoutMs, now, newestMtime(tail.pendingOutputs));
428
496
  return run;
429
497
  }
@@ -431,4 +499,13 @@ function readSubagent(file, now) {
431
499
  return { ...base, error: `could not read: ${err.message}` };
432
500
  }
433
501
  }
502
+ /** A meta file written after the log's last line; an older one may be from before a resumed turn. */
503
+ function metaIsCurrent(file) {
504
+ try {
505
+ return fs.statSync(file.replace(/\.jsonl$/, ".meta.json")).mtimeMs >= fs.statSync(file).mtimeMs - 5_000;
506
+ }
507
+ catch {
508
+ return false;
509
+ }
510
+ }
434
511
  //# sourceMappingURL=claudeCode.js.map
package/dist/bin/cli.js CHANGED
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import { Command } from "commander";
5
5
  import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, dismissCard, 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
+ import { recordPrompt } from "../core/prompts.js";
7
8
  import { digest, digestText, localDate } from "../core/digest.js";
8
9
  import { everywhere, readEvents } from "../core/store.js";
9
10
  import { followEvents } from "../core/follow.js";
@@ -432,13 +433,16 @@ program
432
433
  const open = cards.filter((c) => c.status === "open" && c.kind !== "todo");
433
434
  const todo = cards.filter((c) => c.status === "open" && c.kind === "todo");
434
435
  const answered = cards.filter((c) => c.status === "answered");
435
- const askers = runs.filter((r) => r.asks);
436
+ // a session held by a permission prompt needs the human as much as a card does
437
+ const prompts = runs.filter((r) => r.asks?.via === "permission");
438
+ const askers = runs.filter((r) => r.asks && r.asks.via !== "permission");
436
439
  const reviews = prs.filter((p) => p.reviewRequested && !p.mine);
437
440
  const waiting = prs.filter((p) => !(p.reviewRequested && !p.mine));
438
441
  const ls = snap.listeners ?? [];
439
442
  const out = [];
440
- out.push(head("NEEDS YOU", open.length || "nothing"));
443
+ out.push(head("NEEDS YOU", open.length + prompts.length || "nothing"));
441
444
  out.push(...cardRows(open, v, now, " "));
445
+ out.push(...askRows(prompts, v, now, " "));
442
446
  if (askers.length) {
443
447
  out.push("", head("ASKED IN CHAT, NO CARD", askers.length, " (read from the log)"));
444
448
  out.push(...askRows(askers, v, now, " "));
@@ -471,7 +475,9 @@ program
471
475
  out.push(runsTree(runs, v));
472
476
  const dim = (t) => (v.color ? `\x1b[2m${t}\x1b[0m` : t);
473
477
  const notes = [];
474
- if (snap.github?.error && snap.github.enabled)
478
+ if (snap.github?.failing && snap.github.enabled)
479
+ notes.push(`GitHub: not read since ${(snap.github.fetchedAt ?? "").replace("T", " ").slice(0, 16) || "ever"}; ${snap.github.failedBy ?? "a reader"} failed: ${snap.github.error}. Pull request news is not coming in; see parawatch doctor`);
480
+ else if (snap.github?.error && snap.github.enabled)
475
481
  notes.push(`GitHub: ${snap.github.error}`);
476
482
  const skew = versionSkew(VERSION, runningDesktop()?.version);
477
483
  if (skew)
@@ -603,6 +609,9 @@ program
603
609
  catch {
604
610
  input = {};
605
611
  }
612
+ // a permission prompt is only recorded; the hook never answers it
613
+ if (recordPrompt(input) || input.hook_event_name === "Notification" || input.hook_event_name === "PermissionRequest")
614
+ return;
606
615
  const text = hookContext(input, o.project);
607
616
  if (!text)
608
617
  return;
@@ -6,7 +6,7 @@ import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { CLI_UPDATE, readClient, runningDesktop, versionSkew } from "./clients.js";
9
- import { ghHosts, githubConfig, readCache, runGh } from "./github.js";
9
+ import { ghCommand, ghHosts, githubConfig, githubFailing, readCache, runGh } from "./github.js";
10
10
  import { instructionState } from "./integrate.js";
11
11
  import { listeners } from "./ops.js";
12
12
  import { fold } from "./state.js";
@@ -137,13 +137,22 @@ export async function doctor(input) {
137
137
  if (!ghVersion)
138
138
  add("github", "off", "gh not found, so pull requests are not followed (nothing else needs it)", "install gh and run gh auth login");
139
139
  else {
140
+ if (!input.gh && ghCommand() !== "gh")
141
+ ghVersion += ` (${ghCommand()})`;
140
142
  const hosts = await ghHosts(gh);
141
143
  if (!hosts.length)
142
144
  add("github", "warn", `${ghVersion}, but not signed in`, "gh auth login");
143
145
  else {
144
146
  const c = readCache();
145
- const when = c.fetchedAt ? `last read ${c.fetchedAt.replace("T", " ").slice(0, 16)}` : "not read yet";
146
- add("github", c.error ? "warn" : "ok", `${hosts.join(", ")} · ${c.prs.length} PRs · ${when}${c.error ? ` · ${c.error}` : ""}`, c.error ? "parawatch pr sync" : undefined);
147
+ const at = (t) => t.replace("T", " ").slice(0, 16);
148
+ const when = c.fetchedAt ? `last read ${at(c.fetchedAt)}` : "not read yet";
149
+ if (githubFailing(c)) {
150
+ // this process's gh works, so the failing reader is another one (often the app, opened without Homebrew's PATH)
151
+ const who = c.failedBy ?? "another reader";
152
+ add("github", "warn", `${hosts.join(", ")} · ${when} · ${who} failed at ${at(c.failedAt)}: ${c.error}`, who === "the desktop app" ? 'set "github": { "cli": "<path to gh>" } in config.json if the app cannot find gh, then restart it; parawatch pr sync reads now' : "parawatch pr sync");
153
+ }
154
+ else
155
+ add("github", c.error ? "warn" : "ok", `${hosts.join(", ")} · ${c.prs.length} PRs · ${when}${c.error ? ` · ${c.error}` : ""}`, c.error ? "parawatch pr sync" : undefined);
147
156
  }
148
157
  }
149
158
  }
@@ -64,7 +64,7 @@ export function resolvePrRef(input, cwd = process.cwd()) {
64
64
  throw new UserError(`"${input}" is not a pull request: give a URL, owner/repo#N, or #N inside the repository`);
65
65
  let url;
66
66
  try {
67
- url = execFileSync("gh", ["pr", "view", n[1], "--json", "url", "-q", ".url"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 }).trim();
67
+ url = execFileSync(ghCommand(), ["pr", "view", n[1], "--json", "url", "-q", ".url"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 }).trim();
68
68
  }
69
69
  catch (err) {
70
70
  throw new UserError(`could not look up #${n[1]} with gh here: ${String(err.stderr ?? err).trim().split("\n")[0]}`);
@@ -96,26 +96,86 @@ function writeCache(c) {
96
96
  fs.writeFileSync(tmp, JSON.stringify(c) + "\n");
97
97
  fs.renameSync(tmp, cachePath());
98
98
  }
99
+ /** Where `gh` is usually installed, for a process whose PATH lacks it (an app opened from Finder). */
100
+ export function ghCandidates(platform = process.platform, env = process.env) {
101
+ if (platform === "win32") {
102
+ const dirs = [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs")].filter((d) => !!d);
103
+ const out = dirs.map((d) => path.join(d, "GitHub CLI", "gh.exe"));
104
+ if (env.USERPROFILE)
105
+ out.push(path.join(env.USERPROFILE, "scoop", "shims", "gh.exe"));
106
+ return out;
107
+ }
108
+ return ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/opt/local/bin/gh", "/home/linuxbrew/.linuxbrew/bin/gh", "/usr/bin/gh", "/snap/bin/gh"];
109
+ }
110
+ function onPath(name, env = process.env) {
111
+ const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD").split(";") : [""];
112
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
113
+ if (!dir)
114
+ continue;
115
+ for (const ext of exts)
116
+ if (fs.existsSync(path.join(dir, name + ext.toLowerCase())) || fs.existsSync(path.join(dir, name + ext)))
117
+ return true;
118
+ }
119
+ return false;
120
+ }
121
+ let ghFound;
122
+ /**
123
+ * The `gh` to run: `github.cli` in config.json, else `gh` on this process's PATH, else the first
124
+ * usual install location that exists. Found once per process.
125
+ */
126
+ export function ghCommand() {
127
+ const configured = githubCli();
128
+ if (configured)
129
+ return configured;
130
+ if (ghFound)
131
+ return ghFound;
132
+ ghFound = onPath("gh") ? "gh" : (ghCandidates().find((f) => fs.existsSync(f)) ?? "gh");
133
+ return ghFound;
134
+ }
135
+ function githubCli() {
136
+ try {
137
+ const raw = JSON.parse(fs.readFileSync(path.join(homeDir(), "config.json"), "utf8"));
138
+ return typeof raw.github?.cli === "string" && raw.github.cli ? raw.github.cli : undefined;
139
+ }
140
+ catch {
141
+ return undefined;
142
+ }
143
+ }
144
+ /** A `gh` that could not be started, as opposed to one that ran and failed. */
145
+ export class GhMissing extends Error {
146
+ }
99
147
  export const runGh = (args) => new Promise((resolve, reject) => {
100
- execFile("gh", args, { encoding: "utf8", timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
101
- if (err)
148
+ const cmd = ghCommand();
149
+ execFile(cmd, args, { encoding: "utf8", timeout: 30_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
150
+ if (err && err.code === "ENOENT")
151
+ reject(new GhMissing(`gh not found on this process's PATH or in the usual places (set github.cli in config.json)`));
152
+ else if (err)
102
153
  reject(new Error(String(stderr || err.message).trim().split("\n")[0]));
103
154
  else
104
155
  resolve(stdout);
105
156
  });
106
157
  });
107
- /** Hosts `gh` is signed in to. */
108
- export async function ghHosts(gh = runGh) {
158
+ /** Hosts `gh` is signed in to, or why there are none. */
159
+ export async function ghAuth(gh = runGh) {
109
160
  try {
110
161
  const out = JSON.parse(await gh(["auth", "status", "--json", "hosts"]));
111
- return Object.entries(out.hosts ?? {})
162
+ const hosts = Object.entries(out.hosts ?? {})
112
163
  .filter(([, accounts]) => accounts.some((a) => a.state === "success"))
113
164
  .map(([h]) => h);
165
+ return hosts.length ? { hosts } : { hosts, error: "gh is not signed in" };
114
166
  }
115
- catch {
116
- return [];
167
+ catch (err) {
168
+ return { hosts: [], error: err instanceof GhMissing ? err.message : `gh is not installed or not signed in (${err.message})` };
117
169
  }
118
170
  }
171
+ /** Hosts `gh` is signed in to. */
172
+ export async function ghHosts(gh = runGh) {
173
+ return (await ghAuth(gh)).hosts;
174
+ }
175
+ /** Which kind of process this is, for saying who could not read GitHub. */
176
+ function whoReads() {
177
+ return process.versions.electron ? "the desktop app" : "the command line";
178
+ }
119
179
  const FIELDS = (withQueue) => `
120
180
  number title url state isDraft mergeable reviewDecision updatedAt
121
181
  author { login }
@@ -331,6 +391,17 @@ export function dropReported(fresh, log, since, me = deviceName()) {
331
391
  return fresh.filter((b) => b.type !== "pr.change" || !seen.has(`${b.pr.key}|${b.change}|${b.by ?? ""}`));
332
392
  }
333
393
  // ------------------------------------------------------------------ sync
394
+ /** When this process last failed to read GitHub at all. */
395
+ let failedHere = 0;
396
+ /** For tests: forget this process's last failure and the gh it found. */
397
+ export function resetGithubProcess() {
398
+ failedHere = 0;
399
+ ghFound = undefined;
400
+ }
401
+ /** A failed attempt newer than the last good reading: news is not coming in. */
402
+ export function githubFailing(c) {
403
+ return !!c.failedAt && (!c.fetchedAt || c.failedAt > c.fetchedAt);
404
+ }
334
405
  function fetchingPath() {
335
406
  return path.join(homeDir(), "github.fetching");
336
407
  }
@@ -366,17 +437,29 @@ export async function syncGithub(opts) {
366
437
  const age = prev.fetchedAt ? Date.now() - Date.parse(prev.fetchedAt) : Infinity;
367
438
  if (!opts.force && age < cfg.intervalMinutes * 60_000)
368
439
  return prev;
440
+ // this process failed lately: wait the interval before trying again, others may still read
441
+ if (!opts.force && Date.now() - failedHere < cfg.intervalMinutes * 60_000)
442
+ return prev;
369
443
  if (!takeFetch())
370
444
  return prev;
371
445
  const gh = opts.gh ?? runGh;
372
446
  const started = new Date().toISOString();
373
447
  try {
374
- const hosts = await ghHosts(gh);
448
+ const auth = await ghAuth(gh);
449
+ const hosts = auth.hosts;
375
450
  if (!hosts.length) {
376
- const c = { ...prev, fetchedAt: started, hosts: [], error: "gh is not installed or not signed in" };
377
- writeCache(c);
378
- return c;
451
+ failedHere = Date.now();
452
+ return withLock(() => {
453
+ const latest = readCache();
454
+ if (latest.fetchedAt && latest.fetchedAt > started)
455
+ return latest; // someone else read it meanwhile
456
+ // keep the last good reading and its time: a process whose gh works reads at once
457
+ const c = { ...latest, hosts: latest.hosts ?? [], error: auth.error, failedAt: started, failedBy: whoReads() };
458
+ writeCache(c);
459
+ return c;
460
+ });
379
461
  }
462
+ failedHere = 0;
380
463
  // PRs linked to sessions, and ones seen before, are read by number so merges and closes show
381
464
  const extra = new Map();
382
465
  for (const l of opts.state.prLinks.values())
package/dist/core/ops.js CHANGED
@@ -320,7 +320,7 @@ export function linkPr(link) {
320
320
  const project = link.project?.trim() || claim?.project;
321
321
  appendEvent(() => ({
322
322
  type: "pr.link",
323
- link: { pr: link.pr, session: link.session, project, task: link.task?.trim() || claim?.task },
323
+ link: { pr: link.pr, session: link.session, project, task: link.task?.trim() || claim?.task, auto: link.auto || undefined },
324
324
  }));
325
325
  }
326
326
  export function unlinkPr(key) {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Permission prompts a Claude Code session is showing, recorded by the optional hook
3
+ * (`PermissionRequest`, or a `Notification` about a permission). In the log a prompt looks like a
4
+ * tool call that has not returned yet, the same as a long command; only the hook can tell them apart.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { homeDir } from "./store.js";
9
+ function dir() {
10
+ return path.join(homeDir(), "prompts");
11
+ }
12
+ function file(session) {
13
+ return path.join(dir(), `${session.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
14
+ }
15
+ function oneLine(s, max) {
16
+ const t = s.replace(/\s+/g, " ").trim();
17
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
18
+ }
19
+ /** Whether the hook input is a permission prompt; records it if so. */
20
+ export function recordPrompt(input, now = new Date()) {
21
+ const ev = input.hook_event_name;
22
+ const isPermission = ev === "PermissionRequest" || (ev === "Notification" && (input.notification_type === "permission_prompt" || /\bpermission\b/i.test(input.message ?? "")));
23
+ if (!isPermission || !input.session_id)
24
+ return false;
25
+ let what;
26
+ if (input.tool_name) {
27
+ const inp = input.tool_input ?? {};
28
+ const pick = [inp.description, inp.command, inp.file_path, inp.url].find((x) => typeof x === "string" && !!x);
29
+ what = pick ? `${input.tool_name}: ${oneLine(pick, 80)}` : input.tool_name;
30
+ }
31
+ else if (input.message) {
32
+ // "Claude needs your permission to use Bash"
33
+ const m = /permission to use (.+)$/i.exec(input.message.trim());
34
+ what = m ? m[1].trim() : undefined;
35
+ }
36
+ try {
37
+ const prev = readPrompt(input.session_id);
38
+ // a Notification after the PermissionRequest of the same prompt keeps the richer line
39
+ const keep = prev && !input.tool_name && prev.what && now.getTime() - Date.parse(prev.at) < 60_000;
40
+ fs.mkdirSync(dir(), { recursive: true });
41
+ fs.writeFileSync(file(input.session_id), JSON.stringify(keep ? prev : { at: now.toISOString(), what }) + "\n");
42
+ }
43
+ catch {
44
+ // a hook never fails the session
45
+ }
46
+ return true;
47
+ }
48
+ export function readPrompt(session) {
49
+ try {
50
+ const p = JSON.parse(fs.readFileSync(file(session), "utf8"));
51
+ return typeof p.at === "string" ? p : undefined;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ /**
58
+ * A session whose last tool call has not returned and that showed a permission prompt after its
59
+ * log last moved is waiting for the human to approve: it asks, blocked. A prompt the log has
60
+ * moved past is over.
61
+ */
62
+ export function applyPrompts(runs, read = readPrompt) {
63
+ for (const r of runs) {
64
+ if (r.runtime !== "claude-code" || r.kind !== "session" || r.device || !r.pendingTool)
65
+ continue;
66
+ if (r.state !== "working" && r.state !== "stalled")
67
+ continue;
68
+ const p = read(r.id);
69
+ if (!p || !r.lastActivity || Date.parse(p.at) < Date.parse(r.lastActivity) - 2_000)
70
+ continue;
71
+ r.asks = { text: `approve a tool call${p.what ? `: ${p.what}` : ""}`, via: "permission", blocked: true, at: p.at };
72
+ // like a session that asked in chat, it waits for the human rather than working
73
+ r.state = "idle";
74
+ }
75
+ return runs;
76
+ }
77
+ //# sourceMappingURL=prompts.js.map
package/dist/core/runs.js CHANGED
@@ -5,6 +5,7 @@ import { scanAll } from "../adapters/index.js";
5
5
  import { ACTIVITY_MINUTES, activeMinutes } from "../adapters/jsonl.js";
6
6
  import { archivedSessions } from "./desktop.js";
7
7
  import { canonicalPath, insidePath, namesPath, samePath } from "./paths.js";
8
+ import { applyPrompts } from "./prompts.js";
8
9
  import { deviceName, sharedDir } from "./store.js";
9
10
  /** All checkouts of a project path: the path itself plus its git worktrees. */
10
11
  function rootsOf(p, cache) {
@@ -391,7 +392,7 @@ export function collectRuns(state, q, now = Date.now()) {
391
392
  const sinceMs = now - q.sinceHours * 3_600_000;
392
393
  // a log file can be touched (title records, snapshots) long after its last real activity
393
394
  const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
394
- let runs = endArchived(attribute(scanAll({ sinceMs, now }, q.runtimes), state), archivedSessions()).filter(recent);
395
+ let runs = applyPrompts(endArchived(attribute(scanAll({ sinceMs, now }, q.runtimes), state), archivedSessions())).filter(recent);
395
396
  // a narrower reading (one runtime) would hide the rest from the other machines
396
397
  if (!q.runtimes)
397
398
  shareRuns(runs, now);
@@ -1,4 +1,4 @@
1
- import { prViews, keyOf, parsePrRef, readCache, githubConfig } from "./github.js";
1
+ import { prViews, keyOf, parsePrRef, readCache, githubConfig, githubFailing } from "./github.js";
2
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";
@@ -30,22 +30,34 @@ export function snapshot(opts = {}) {
30
30
  cards,
31
31
  runs,
32
32
  prs,
33
- github: { enabled, fetchedAt: cache.fetchedAt, error: cache.error },
33
+ github: { enabled, fetchedAt: cache.fetchedAt, error: cache.error, failing: githubFailing(cache) || undefined, failedBy: cache.failedBy, failedAt: cache.failedAt },
34
34
  listeners: listeners(),
35
35
  devices: sharedDir() ? { me: deviceName(), others: eventFiles().map((f) => f.device).filter((d) => d !== deviceName()) } : undefined,
36
36
  };
37
37
  }
38
- /** A pull request a session's log shows it opened belongs to that session, unless linked already. */
38
+ /**
39
+ * A pull request a session's log shows it opened belongs to that session, unless linked already.
40
+ * An earlier automatic link to another session gives way when that session's log no longer shows
41
+ * it opening the PR (it was read wrongly); a link made with `parawatch pr link` never does.
42
+ */
39
43
  function linkCreatedPrs(runs, state) {
40
44
  let added = false;
41
45
  for (const r of runs) {
42
46
  for (const url of r.createdPrs ?? []) {
43
47
  const key = keyOf(url);
44
48
  const pr = parsePrRef(url);
45
- if (!key || !pr || state.prLinks.has(key))
49
+ if (!key || !pr)
46
50
  continue;
47
- linkPr({ pr, session: r.id, project: r.project, task: r.task });
48
- state.prLinks.set(key, { pr, session: r.id, project: r.project, at: new Date().toISOString() });
51
+ const had = state.prLinks.get(key);
52
+ if (had) {
53
+ if (!had.auto || !had.session || r.id.startsWith(had.session))
54
+ continue;
55
+ const owner = runs.find((x) => x.id.startsWith(had.session));
56
+ if (!owner || (owner.createdPrs ?? []).some((u) => keyOf(u) === key))
57
+ continue;
58
+ }
59
+ linkPr({ pr, session: r.id, project: r.project, task: r.task, auto: true });
60
+ state.prLinks.set(key, { pr, session: r.id, project: r.project, auto: true, at: new Date().toISOString() });
49
61
  added = true;
50
62
  }
51
63
  }
@@ -54,7 +66,8 @@ function linkCreatedPrs(runs, state) {
54
66
  /** A question asked in chat is no longer pending once the session put it on a card. */
55
67
  function quietAnsweredAsks(runs, cards) {
56
68
  for (const r of runs) {
57
- if (!r.asks)
69
+ // a permission prompt holds the session whatever cards it has open
70
+ if (!r.asks || r.asks.via === "permission")
58
71
  continue;
59
72
  const turn = r.lastPromptAt ?? "";
60
73
  if (cards.some((c) => c.status !== "closed" && c.session && r.id.startsWith(c.session) && c.openedAt >= turn))
@@ -48,8 +48,12 @@ export function fold(events, map) {
48
48
  }
49
49
  case "card.comment": {
50
50
  const c = s.cards.get(ev.card);
51
- if (c)
52
- c.comments.push({ seq: ev.seq, at: ev.ts, by: ev.by, text: ev.text });
51
+ if (!c)
52
+ break;
53
+ c.comments.push({ seq: ev.seq, at: ev.ts, by: ev.by, text: ev.text });
54
+ // The human wrote without choosing and the agent replied: the choice is still the human's to make
55
+ if (ev.by !== "human" && c.status === "answered" && c.options?.length && !c.answers.some((a) => a.choice !== undefined && !a.late))
56
+ c.status = "open";
53
57
  break;
54
58
  }
55
59
  case "card.edit": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parawatch",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
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",