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.
- package/dist/adapters/claudeCode.js +82 -5
- package/dist/bin/cli.js +12 -3
- package/dist/core/doctor.js +12 -3
- package/dist/core/github.js +95 -12
- package/dist/core/ops.js +1 -1
- package/dist/core/prompts.js +77 -0
- package/dist/core/runs.js +2 -1
- package/dist/core/snapshot.js +20 -7
- package/dist/core/state.js +6 -2
- package/package.json +1 -1
|
@@ -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 (
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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?.
|
|
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;
|
package/dist/core/doctor.js
CHANGED
|
@@ -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
|
|
146
|
-
|
|
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
|
}
|
package/dist/core/github.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
448
|
+
const auth = await ghAuth(gh);
|
|
449
|
+
const hosts = auth.hosts;
|
|
375
450
|
if (!hosts.length) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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);
|
package/dist/core/snapshot.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
49
|
+
if (!key || !pr)
|
|
46
50
|
continue;
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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))
|
package/dist/core/state.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|