parawatch 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,491 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import { Command } from "commander";
5
+ import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, commentCard, consumerName, editCard, endRun, linkPr, listeners, listenersFor, noteRun, openCard, prEvent, readInbox, removeProject, startListening, startRun, unlinkPr, waitInbox, } from "../core/ops.js";
6
+ import { githubConfig, readCache, resolvePrRef, syncGithub, prViews } from "../core/github.js";
7
+ import { digest, digestText, localDate } from "../core/digest.js";
8
+ import { readEvents } from "../core/store.js";
9
+ import { followEvents } from "../core/follow.js";
10
+ import { fold } from "../core/state.js";
11
+ import { collectRuns } from "../core/runs.js";
12
+ import { askRows, cardDetail, cardLine, cardRows, inboxLine, prRows, runsTree, view } from "../core/format.js";
13
+ import { hookContext, notify, writeInstructions } from "../core/integrate.js";
14
+ import { installSkill, skillSource, skillTargets } from "../core/skill.js";
15
+ import { cardContextLine, resolveGoto } from "../core/desktop.js";
16
+ import { snapshot } from "../core/snapshot.js";
17
+ import { SCHEMA_VERSION } from "../core/types.js";
18
+ const collect = (v, prev = []) => [...prev, v];
19
+ const int = (v) => {
20
+ const n = Number(v);
21
+ if (!Number.isInteger(n))
22
+ throw new UserError(`"${v}" is not a whole number`);
23
+ return n;
24
+ };
25
+ const print = (x) => process.stdout.write(JSON.stringify(x) + "\n");
26
+ /** The agent session running this command, when its runtime says so. */
27
+ function currentSession() {
28
+ return process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID || undefined;
29
+ }
30
+ const program = new Command();
31
+ program
32
+ .name("parawatch")
33
+ .description("See what your agents are doing, what needs you, and wake the right agent when you answer.")
34
+ .version("0.2.0");
35
+ /** Read GitHub when the last reading is old; never fails the command. */
36
+ async function refreshGithub(force = false) {
37
+ try {
38
+ await syncGithub({ state: fold(readEvents()), force });
39
+ }
40
+ catch (err) {
41
+ if (force)
42
+ throw err;
43
+ }
44
+ }
45
+ // ------------------------------------------------------------------ cards
46
+ function cardCommand(kind, name, summary) {
47
+ return program
48
+ .command(`${name} <title>`)
49
+ .description(summary)
50
+ .requiredOption("-p, --project <name>", "project this card belongs to")
51
+ .option("-t, --task <task>", "task within the project")
52
+ .option("-b, --body <text>", "two or three lines of context")
53
+ .option("--attach <file>", "image, video or file to look at (repeatable)", collect)
54
+ .option("--from <who>", "who is asking (defaults to the calling session's claimed title)")
55
+ .option("--session <id>", "the agent session asking (defaults to the calling session)")
56
+ .option("--since <date>", "when the matter first came up, if earlier (YYYY-MM-DD)")
57
+ .option("--json", "print the card as JSON");
58
+ }
59
+ /** Options every card kind shares. */
60
+ function common(o) {
61
+ return {
62
+ project: o.project,
63
+ task: o.task,
64
+ body: o.body,
65
+ attach: o.attach,
66
+ from: o.from,
67
+ session: o.session ?? currentSession(),
68
+ since: o.since,
69
+ };
70
+ }
71
+ function finishCard(input, json) {
72
+ const card = openCard(input);
73
+ notify(card);
74
+ if (json)
75
+ print(card);
76
+ else
77
+ process.stdout.write(`${card.id}\n`);
78
+ }
79
+ cardCommand("decide", "ask", "ask the human to decide, or to approve a prepared action")
80
+ .option("-o, --option <text>", "a choice (repeatable)", collect)
81
+ .option("-r, --recommend <n>", "the recommended choice, 1-based", int)
82
+ .option("--action <text>", "the exact command or step that runs on yes (options default to Do it / Don't)")
83
+ .option("--preview <file>", "a file with the text the action sends or changes (a message, a diff)")
84
+ .action((title, o) => finishCard({ kind: "decide", title, ...common(o), options: o.option, recommend: o.recommend, action: o.action, preview: o.preview }, o.json));
85
+ cardCommand("try", "try", "ask the human to try a build, watch a video or look at a screen")
86
+ .option("--open <path>", "app, file or URL to open (repeatable)", collect)
87
+ .option("-l, --look <text>", "what to look at")
88
+ .action((title, o) => finishCard({ kind: "try", title, ...common(o), open: o.open, look: o.look }, o.json));
89
+ cardCommand("block", "block", "something only the human can do, and you are stuck until they do it")
90
+ .action((title, o) => finishCard({ kind: "block", title, ...common(o) }, o.json));
91
+ cardCommand("todo", "todo", "something only the human can do, but nothing waits on it now")
92
+ .action((title, o) => finishCard({ kind: "todo", title, ...common(o) }, o.json));
93
+ program
94
+ .command("edit <card>")
95
+ .description("change a card: its kind, wording, task, who asked or when it came up (an empty value clears)")
96
+ .option("-k, --kind <kind>", "decide, try, block or todo")
97
+ .option("--title <text>", "new title")
98
+ .option("-b, --body <text>", "new body")
99
+ .option("-t, --task <task>", "task within the project")
100
+ .option("--from <who>", "who asked")
101
+ .option("--session <id>", "the agent session that asked")
102
+ .option("--since <date>", "when the matter first came up (YYYY-MM-DD)")
103
+ .option("--json", "print the card as JSON")
104
+ .action((id, o) => {
105
+ 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 });
106
+ if (o.json)
107
+ print(card);
108
+ else
109
+ process.stdout.write(cardLine(card) + "\n");
110
+ });
111
+ program
112
+ .command("goto <target>")
113
+ .description("open the agent session behind a card, a pull request (URL, owner/repo#N, repo#N) or a session id in the Claude desktop app")
114
+ .option("--copy", "also put a line about the card on the clipboard, to paste into that session")
115
+ .option("--print", "print the link instead of opening it")
116
+ .action((target, o) => {
117
+ const g = resolveGoto(target, fold(readEvents()));
118
+ if (o.copy && g.card) {
119
+ const line = cardContextLine(g.card);
120
+ try {
121
+ execFileSync("pbcopy", { input: line });
122
+ }
123
+ catch {
124
+ process.stderr.write(`parawatch: could not copy to the clipboard; the line is: ${line}\n`);
125
+ }
126
+ }
127
+ if (!g.url)
128
+ throw new UserError(`${g.name} is not a Claude desktop session. In a terminal: claude --resume ${g.session}`);
129
+ if (o.print) {
130
+ process.stdout.write(g.url + "\n");
131
+ return;
132
+ }
133
+ execFileSync("open", [g.url]);
134
+ process.stdout.write(`Opened ${g.name}${g.desktop?.archived ? " (archived)" : ""}\n`);
135
+ });
136
+ program
137
+ .command("answer <card>")
138
+ .description("answer a card (this is what wakes the waiting agent)")
139
+ .option("-c, --choice <n>", "chosen option, 1-based", int)
140
+ .option("-m, --comment <text>", "free text")
141
+ .option("--attach <file>", "a screenshot or file (repeatable)", collect)
142
+ .option("--by <who>", "who answers", "human")
143
+ .action((id, o) => answerCard(id, { choice: o.choice, comment: o.comment, attach: o.attach, by: o.by }));
144
+ program
145
+ .command("comment <card> <text>")
146
+ .description("add a comment to a card")
147
+ .option("--by <who>", "who comments (use 'human' to wake the agent)", "agent")
148
+ .action((id, text, o) => commentCard(id, text, o.by));
149
+ program
150
+ .command("close <card>")
151
+ .description("close a card once it has been acted on")
152
+ .option("-n, --note <text>", "what was done")
153
+ .action((id, o) => closeCard(id, o.note));
154
+ program
155
+ .command("show <card>")
156
+ .description("show one card with its answers")
157
+ .option("--json", "JSON output")
158
+ .action((id, o) => {
159
+ const card = fold(readEvents()).cards.get(id);
160
+ if (!card)
161
+ throw new UserError(`no card "${id}"`);
162
+ if (o.json) {
163
+ print(card);
164
+ return;
165
+ }
166
+ const waits = card.status === "closed" ? [] : listenersFor(card, listeners());
167
+ const delivery = card.status === "closed"
168
+ ? ""
169
+ : waits.length
170
+ ? `\nDelivery: an agent is waiting (${waits.map((l) => l.consumer).join(", ")}); an answer wakes it now.`
171
+ : "\nDelivery: no agent is waiting; an answer is kept until the agent next checks (its next turn, or its next wait).";
172
+ process.stdout.write(cardDetail(card) + delivery + "\n");
173
+ });
174
+ program
175
+ .command("cards")
176
+ .description("list cards (open and answered by default)")
177
+ .option("-p, --project <name>", "only this project")
178
+ .option("-a, --all", "include closed cards")
179
+ .option("--json", "JSON output")
180
+ .action((o) => {
181
+ const cards = [...fold(readEvents()).cards.values()].filter((c) => (o.all || c.status !== "closed") && (!o.project || c.project === o.project));
182
+ if (o.json)
183
+ print({ v: SCHEMA_VERSION, cards });
184
+ else
185
+ process.stdout.write(cards.length ? cardRows(cards, view()).join("\n") + "\n" : "No cards.\n");
186
+ });
187
+ // ------------------------------------------------------------------ inbox
188
+ function inboxOptions(c) {
189
+ return c
190
+ .option("-p, --project <name>", "only answers for this project")
191
+ .option("--card <id>", "only answers to this card")
192
+ .option("--as <consumer>", "cursor name (defaults to the project)")
193
+ .option("--json", "JSON output");
194
+ }
195
+ inboxOptions(program.command("wait").description("block until the human answers, print the answers and exit"))
196
+ .option("--timeout <seconds>", "give up after this long (exit code 2); 0 waits forever", int, 0)
197
+ .action(async (o) => {
198
+ startListening({ consumer: consumerName(o), project: o.project, card: o.card, session: currentSession() });
199
+ // while waiting, keep GitHub fresh so news on linked pull requests wakes this wait
200
+ const tick = () => void refreshGithub();
201
+ tick();
202
+ const timer = setInterval(tick, 60_000);
203
+ timer.unref();
204
+ const items = await waitInbox(consumerName(o), { project: o.project, card: o.card }, o.timeout * 1000);
205
+ clearInterval(timer);
206
+ if (!items.length) {
207
+ process.exitCode = 2;
208
+ return;
209
+ }
210
+ if (o.json)
211
+ print({ v: SCHEMA_VERSION, items });
212
+ else
213
+ process.stdout.write(items.map(inboxLine).join("\n") + "\n");
214
+ });
215
+ inboxOptions(program.command("inbox").description("print unhandled answers without waiting"))
216
+ .option("--peek", "do not mark them as seen")
217
+ .action((o) => {
218
+ const items = readInbox(consumerName(o), { project: o.project, card: o.card }, !o.peek);
219
+ if (o.json)
220
+ print({ v: SCHEMA_VERSION, items });
221
+ else if (items.length)
222
+ process.stdout.write(items.map(inboxLine).join("\n") + "\n");
223
+ });
224
+ program
225
+ .command("watch")
226
+ .description("stream every new event as one JSON line (for plugins and monitors)")
227
+ .option("--from <seq>", "replay events after this sequence number first", int)
228
+ .action(async (o) => {
229
+ followEvents(print, o.from);
230
+ await new Promise(() => { });
231
+ });
232
+ // ------------------------------------------------------------------ runs
233
+ program
234
+ .command("runs")
235
+ .description("agents seen in their logs, grouped by project")
236
+ .option("-p, --project <name>", "only this project")
237
+ .option("--since <hours>", "look at logs touched in the last N hours", int, 6)
238
+ .option("-a, --all", "include runs that ended a while ago")
239
+ .option("--runtime <name>", "only this adapter (repeatable)", collect)
240
+ .option("--every", "list each run of a scheduled task instead of one row per task")
241
+ .option("-w, --wide", "show runtime and ids")
242
+ .option("--json", "JSON output")
243
+ .action((o) => {
244
+ const runs = collectRuns(fold(readEvents()), { sinceHours: o.since, project: o.project, includeEnded: o.all, runtimes: o.runtime, keepRepeats: o.every });
245
+ if (o.json)
246
+ print({ v: SCHEMA_VERSION, runs });
247
+ else
248
+ process.stdout.write((runs.length ? runsTree(runs, view({ wide: o.wide, all: o.all })) : "Nothing running.") + "\n");
249
+ });
250
+ const run = program.command("run").description("report a job that leaves no agent log");
251
+ run
252
+ .command("start <title>")
253
+ .requiredOption("-p, --project <name>", "project")
254
+ .option("-t, --task <task>", "task")
255
+ .option("--parent <id>", "the run that started this one (defaults to the calling session)")
256
+ .option("--heartbeat <seconds>", "count the run as stalled after this long without a beat, a note or log output", int)
257
+ .option("--log <file>", "a log file the job writes; each change counts as a beat")
258
+ .action((title, o) => {
259
+ process.stdout.write(startRun({ title, project: o.project, task: o.task, parent: o.parent ?? currentSession(), heartbeat: o.heartbeat, log: o.log }) + "\n");
260
+ });
261
+ run.command("note <id> <text>").description("say how it is going (also counts as a beat)").action((id, text) => noteRun(id, text));
262
+ run.command("beat <id>").description("say it is still alive").action((id) => beatRun(id));
263
+ run
264
+ .command("end <id>")
265
+ .option("--failed", "it failed")
266
+ .option("-n, --note <text>", "how it ended")
267
+ .action((id, o) => endRun(id, o.failed ? "failed" : "done", o.note));
268
+ program
269
+ .command("claim [session]")
270
+ .description("say which project and task an agent session works on (id prefix, 6+ characters; defaults to the calling session)")
271
+ .option("-p, --project <name>", "project (may be left out once the session has claimed one)")
272
+ .option("-t, --task <task>", "task: a ticket key, a PR, a short name")
273
+ .option("--role <role>", "for example lead or worker")
274
+ .option("--title <title>", "name to show")
275
+ .option("--kind <kind>", "'scheduled' for a session a schedule starts again and again")
276
+ .option("--done <summary>", "the work is finished; one line on how it ended")
277
+ .action((session, o) => {
278
+ const id = session ?? currentSession();
279
+ if (!id)
280
+ throw new UserError("no session id given and none found in the environment (CLAUDE_CODE_SESSION_ID, CODEX_THREAD_ID)");
281
+ if (o.kind !== undefined && o.kind !== "scheduled")
282
+ throw new UserError("--kind can only be 'scheduled'");
283
+ const c = claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title, kind: o.kind, done: o.done });
284
+ process.stdout.write(`${id.slice(0, 8)} → ${c.project}${c.task ? ` / ${c.task}` : ""}${c.role ? ` (${c.role})` : ""}${c.done ? " · done" : ""}\n`);
285
+ });
286
+ // ------------------------------------------------------------------ pull requests
287
+ const pr = program.command("pr").description("pull requests and what they wait on (reads GitHub through gh)");
288
+ pr.command("link <pr>")
289
+ .description("say this pull request belongs to the calling session: news on it wakes the session's wait")
290
+ .option("-p, --project <name>", "project (defaults to the session's claim)")
291
+ .option("-t, --task <task>", "task (defaults to the session's claim)")
292
+ .option("--session <id>", "the owning session (defaults to the calling session)")
293
+ .action((ref, o) => {
294
+ const r = resolvePrRef(ref);
295
+ const session = o.session ?? currentSession();
296
+ linkPr({ pr: r, session, project: o.project, task: o.task });
297
+ process.stdout.write(`${r.repo}#${r.number} → ${session ? session.slice(0, 8) : o.project ?? "no session"}\n`);
298
+ });
299
+ pr.command("unlink <pr>")
300
+ .description("forget which session owns a pull request")
301
+ .action((ref) => unlinkPr(resolvePrRef(ref).key));
302
+ pr.command("list")
303
+ .description("open pull requests you wrote or are asked to review, with what each waits on")
304
+ .option("-p, --project <name>", "only this project")
305
+ .option("--offline", "do not read GitHub, show the last reading")
306
+ .option("--json", "JSON output")
307
+ .action(async (o) => {
308
+ if (!o.offline)
309
+ await refreshGithub();
310
+ const state = fold(readEvents());
311
+ const runs = collectRuns(state, { sinceHours: 72 });
312
+ let prs = prViews(readCache(), state, runs);
313
+ if (o.project)
314
+ prs = prs.filter((p) => p.project === o.project);
315
+ if (o.json)
316
+ print({ v: SCHEMA_VERSION, prs });
317
+ else
318
+ process.stdout.write(prs.length ? prRows(prs, view()).join("\n") + "\n" : githubNote() ?? "No open pull requests.\n");
319
+ });
320
+ pr.command("sync")
321
+ .description("read GitHub now")
322
+ .action(async () => {
323
+ const c = await syncGithub({ state: fold(readEvents()), force: true });
324
+ if (c.error)
325
+ process.stderr.write(`parawatch: ${c.error}\n`);
326
+ process.stdout.write(`${c.prs.filter((p) => p.state === "open").length} open pull requests on ${(c.hosts ?? []).join(", ") || "no host"}\n`);
327
+ });
328
+ pr.command("event <pr> <change>")
329
+ .description("record news another tool saw on a pull request (a merge bot, a ticket system); it wakes the owner like GitHub's own")
330
+ .option("-m, --text <text>", "what happened")
331
+ .option("--by <who>", "who did it")
332
+ .action((ref, change, o) => prEvent(resolvePrRef(ref), change, { text: o.text, by: o.by }));
333
+ function githubNote() {
334
+ if (!githubConfig().enabled)
335
+ return "GitHub is turned off in config.json.\n";
336
+ const c = readCache();
337
+ if (c.hosts && !c.hosts.length)
338
+ return "GitHub: gh is not installed or not signed in (gh auth login).\n";
339
+ return undefined;
340
+ }
341
+ program
342
+ .command("digest")
343
+ .description("what each session did on a day: work claimed, cards, jobs, pull request news, how it ended")
344
+ .option("-d, --date <YYYY-MM-DD>", "the day (default: today)")
345
+ .option("--json", "JSON output")
346
+ .action((o) => {
347
+ const d = digest(o.date ?? localDate());
348
+ if (o.json)
349
+ print({ v: SCHEMA_VERSION, ...d });
350
+ else
351
+ process.stdout.write(digestText(d) + "\n");
352
+ });
353
+ // ------------------------------------------------------------------ status
354
+ program
355
+ .command("status")
356
+ .description("everything at a glance: what needs you, what waits on others, then what is running")
357
+ .option("-p, --project <name>", "only this project")
358
+ .option("--since <hours>", "look at logs touched in the last N hours", int, 6)
359
+ .option("--offline", "do not read GitHub, show the last reading")
360
+ .option("-w, --wide", "show runtime and ids")
361
+ .option("--json", "JSON output")
362
+ .action(async (o) => {
363
+ if (!o.offline)
364
+ await refreshGithub();
365
+ const snap = snapshot({ sinceHours: o.since, project: o.project });
366
+ if (o.json) {
367
+ print(snap);
368
+ return;
369
+ }
370
+ const { cards, runs } = snap;
371
+ const prs = snap.prs ?? [];
372
+ const v = view({ wide: o.wide });
373
+ const now = Date.now();
374
+ const head = (title, n, ...extra) => v.color ? `\x1b[1m${title}\x1b[0m \x1b[2m${n}${extra.join("")}\x1b[0m` : `${title} ${n}${extra.join("")}`;
375
+ const open = cards.filter((c) => c.status === "open" && c.kind !== "todo");
376
+ const todo = cards.filter((c) => c.status === "open" && c.kind === "todo");
377
+ const answered = cards.filter((c) => c.status === "answered");
378
+ const asking = runs.filter((r) => r.asks);
379
+ const reviews = prs.filter((p) => p.reviewRequested && !p.mine);
380
+ const waiting = prs.filter((p) => !(p.reviewRequested && !p.mine));
381
+ const ls = snap.listeners ?? [];
382
+ const out = [];
383
+ out.push(head("NEEDS YOU", open.length || "nothing"));
384
+ out.push(...cardRows(open, v, now, " "));
385
+ if (asking.length) {
386
+ out.push("", head("ASKED IN CHAT, NO CARD", asking.length, " (read from the log)"));
387
+ out.push(...askRows(asking, v, now, " "));
388
+ }
389
+ if (reviews.length) {
390
+ out.push("", head("REVIEWS ASKED OF YOU", reviews.length));
391
+ out.push(...prRows(reviews, v, now, " "));
392
+ }
393
+ if (todo.length) {
394
+ out.push("", head("TO DO, NOTHING WAITS ON IT", todo.length));
395
+ out.push(...cardRows(todo, v, now, " "));
396
+ }
397
+ if (answered.length) {
398
+ const notes = {};
399
+ for (const c of answered)
400
+ if (!listenersFor(c, ls).length)
401
+ notes[c.id] = "no agent waiting";
402
+ out.push("", head("ANSWERED, WAITING FOR THE AGENT", answered.length));
403
+ out.push(...cardRows(answered, v, now, " ", notes));
404
+ }
405
+ if (waiting.length) {
406
+ const stuck = waiting.filter((p) => p.stalled).length;
407
+ out.push("", head("WAITING ON OTHERS", waiting.length, stuck ? ` · ${stuck} stuck` : ""));
408
+ out.push(...prRows(waiting, v, now, " "));
409
+ }
410
+ const working = runs.filter((r) => r.state === "working" || r.state === "waiting").length;
411
+ out.push("", head("AGENTS", runs.length ? `${working} working` : "nothing running"));
412
+ if (runs.length)
413
+ out.push(runsTree(runs, v));
414
+ if (snap.github?.error && snap.github.enabled)
415
+ out.push("", v.color ? `\x1b[2mGitHub: ${snap.github.error}\x1b[0m` : `GitHub: ${snap.github.error}`);
416
+ process.stdout.write(out.join("\n") + "\n");
417
+ });
418
+ // ------------------------------------------------------------------ skill
419
+ const skill = program.command("skill").description("the agent skill that teaches agents to use parawatch");
420
+ skill
421
+ .command("path")
422
+ .description("print where the skill lives")
423
+ .action(() => {
424
+ process.stdout.write(skillSource() + "\n");
425
+ });
426
+ skill
427
+ .command("install")
428
+ .description("link the skill into Claude Code (~/.claude/skills) and Codex (~/.codex/skills)")
429
+ .option("--runtime <name>", "only this runtime: claude-code or codex (repeatable)", collect)
430
+ .option("--force", "replace a folder that is already there")
431
+ .action((o) => {
432
+ for (const r of installSkill(skillTargets(), { only: o.runtime, force: o.force })) {
433
+ process.stdout.write(`${r.runtime.padEnd(12)} ${r.result.padEnd(15)} ${r.path}${r.note ? ` (${r.note})` : ""}\n`);
434
+ }
435
+ });
436
+ // ------------------------------------------------------------------ setup
437
+ const project = program.command("project").description("projects you work on");
438
+ project
439
+ .command("add <name> [paths...]")
440
+ .description("register a project and the checkouts or folders it lives in")
441
+ .action((name, paths) => addProject(name, paths.length ? paths : [process.cwd()]));
442
+ project.command("remove <name>").action((name) => removeProject(name));
443
+ project
444
+ .command("list")
445
+ .option("--json", "JSON output")
446
+ .action((o) => {
447
+ const ps = [...fold(readEvents()).projects.values()];
448
+ if (o.json)
449
+ print({ v: SCHEMA_VERSION, projects: ps });
450
+ else
451
+ process.stdout.write(ps.map((p) => `${p.name} ${p.paths.join(", ")}`).join("\n") + (ps.length ? "\n" : "No projects.\n"));
452
+ });
453
+ program
454
+ .command("init")
455
+ .description("add parawatch instructions for agents to an instruction file")
456
+ .option("-f, --file <path>", "file to write", "AGENTS.md")
457
+ .option("-p, --project <name>", "project name to put in the examples")
458
+ .action((o) => {
459
+ const changed = writeInstructions(o.file, o.project);
460
+ process.stdout.write(changed ? `Updated ${o.file}\n` : `${o.file} already up to date\n`);
461
+ });
462
+ program
463
+ .command("hook <runtime>")
464
+ .description("hook entry point; 'claude-code' reads the hook JSON on stdin")
465
+ .option("-p, --project <name>", "project, when it cannot be told from the session")
466
+ .action((runtime, o) => {
467
+ if (runtime !== "claude-code")
468
+ throw new UserError(`no hook for "${runtime}"`);
469
+ let input = {};
470
+ try {
471
+ input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
472
+ }
473
+ catch {
474
+ input = {};
475
+ }
476
+ const text = hookContext(input, o.project);
477
+ if (!text)
478
+ return;
479
+ if (input.hook_event_name)
480
+ print({ hookSpecificOutput: { hookEventName: input.hook_event_name, additionalContext: text } });
481
+ else
482
+ process.stdout.write(text + "\n");
483
+ });
484
+ program.parseAsync().catch((err) => {
485
+ if (err instanceof UserError) {
486
+ process.stderr.write(`parawatch: ${err.message}\n`);
487
+ process.exit(1);
488
+ }
489
+ throw err;
490
+ });
491
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,102 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { parsePrRef } from "./github.js";
5
+ import { sessionTitle, UserError } from "./ops.js";
6
+ /**
7
+ * Where the Claude desktop app keeps its Code sessions: one JSON file per session, named
8
+ * `local_<id>.json`, holding the CLI session id the app runs (`cliSessionId`).
9
+ */
10
+ export function desktopSessionsDir() {
11
+ return (process.env.PARAWATCH_CLAUDE_DESKTOP_DIR ||
12
+ path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code-sessions"));
13
+ }
14
+ function* sessionFiles(dir, depth = 0) {
15
+ let entries;
16
+ try {
17
+ entries = fs.readdirSync(dir, { withFileTypes: true });
18
+ }
19
+ catch {
20
+ return;
21
+ }
22
+ for (const e of entries) {
23
+ const p = path.join(dir, e.name);
24
+ if (e.isDirectory() && depth < 3)
25
+ yield* sessionFiles(p, depth + 1);
26
+ else if (e.isFile() && /^local_[A-Za-z0-9-]+\.json$/.test(e.name))
27
+ yield p;
28
+ }
29
+ }
30
+ /** The desktop app session running a CLI session (full id or a prefix of 6+ characters). */
31
+ export function findDesktopSession(cliSession, dir = desktopSessionsDir()) {
32
+ if (cliSession.length < 6)
33
+ return undefined;
34
+ let best;
35
+ for (const file of sessionFiles(dir)) {
36
+ let d;
37
+ try {
38
+ d = JSON.parse(fs.readFileSync(file, "utf8"));
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ const ids = [d.cliSessionId, d.lastAssistantCliSessionId].filter((x) => typeof x === "string");
44
+ if (!ids.some((x) => x.startsWith(cliSession)))
45
+ continue;
46
+ const s = {
47
+ id: typeof d.sessionId === "string" ? d.sessionId : path.basename(file, ".json"),
48
+ cliSessionId: ids[0],
49
+ title: typeof d.title === "string" ? d.title : undefined,
50
+ cwd: typeof d.cwd === "string" ? d.cwd : undefined,
51
+ archived: d.isArchived === true,
52
+ lastActivityAt: typeof d.lastActivityAt === "number" ? d.lastActivityAt : 0,
53
+ };
54
+ // prefer a live session, then the most recent one
55
+ if (!best || (best.archived && !s.archived) || (best.archived === s.archived && s.lastActivityAt > best.lastActivityAt))
56
+ best = s;
57
+ }
58
+ return best;
59
+ }
60
+ /** A link that opens the session in the Claude desktop app. */
61
+ export function desktopSessionUrl(s) {
62
+ return `claude://code/continue?session=${encodeURIComponent(s.id)}&source=parawatch`;
63
+ }
64
+ /** The session that owns a pull request: by URL, `owner/repo#N`, or `#N` / `repo#N` when only one matches. */
65
+ function prSession(target, state) {
66
+ const ref = parsePrRef(target);
67
+ if (ref) {
68
+ const link = state.prLinks.get(ref.key);
69
+ if (!link)
70
+ throw new UserError(`no session owns ${ref.repo}#${ref.number}; link it from that session with: parawatch pr link ${ref.url}`);
71
+ if (!link.session)
72
+ throw new UserError(`${ref.repo}#${ref.number} is linked to a project but not to a session`);
73
+ return link.session;
74
+ }
75
+ const m = /^(?:([^/\s#]+))?#(\d+)$/.exec(target.trim());
76
+ if (!m)
77
+ return undefined;
78
+ const hits = [...state.prLinks.values()].filter((l) => l.pr.number === Number(m[2]) && (!m[1] || l.pr.repo.split("/")[1] === m[1]));
79
+ if (!hits.length)
80
+ throw new UserError(`no session owns a pull request ${target}`);
81
+ if (hits.length > 1)
82
+ throw new UserError(`${target} matches ${hits.map((h) => h.pr.repo + "#" + h.pr.number).join(", ")}; give owner/repo#N`);
83
+ if (!hits[0].session)
84
+ throw new UserError(`${target} is linked to a project but not to a session`);
85
+ return hits[0].session;
86
+ }
87
+ /** Where `goto` leads for a card id, a pull request, or a session id (prefix). */
88
+ export function resolveGoto(target, state) {
89
+ const card = state.cards.get(target);
90
+ const session = card ? card.session : (prSession(target, state) ?? target);
91
+ if (!session) {
92
+ throw new UserError(`card ${target} does not say which session opened it (set one with: parawatch edit ${target} --session <id>)`);
93
+ }
94
+ const desktop = findDesktopSession(session);
95
+ const name = sessionTitle(state.claims, session) ?? desktop?.title ?? session.slice(0, 8);
96
+ return { name, session, card, desktop, url: desktop ? desktopSessionUrl(desktop) : undefined };
97
+ }
98
+ /** A line to paste into the session so it knows which card the human means. */
99
+ export function cardContextLine(card) {
100
+ return `About parawatch card ${card.id} ("${card.title}"): `;
101
+ }
102
+ //# sourceMappingURL=desktop.js.map